Cours de Systèmes Distribués

Bienvenue dans le Cours de Systèmes Distribués ! Ce cours vous guidera des concepts fondamentaux jusqu'à la construction d'un système fonctionnel basé sur le consensus.

Pourquoi Apprendre les Systèmes Distribués ?

Les systèmes distribués sont partout. Chaque fois que vous utilisez un service web moderne, vous interagissez avec un système distribué :

  • Plateformes de médias sociaux gérant des milliards d'utilisateurs
  • Sites de commerce électronique traitant des millions de transactions
  • Services de streaming diffusant du contenu à l'échelle mondiale
  • Bases de données cloud stockant et répliquant des données à travers les continents

Comprendre les systèmes distribués est essentiel pour construire des applications évolutives et fiables.

Aperçu du Cours

Ce cours enseigne les concepts des systèmes distribués à travers une mise en œuvre pratique. Sur 10 sessions, vous construirez quatre applications distribuées de complexité croissante :

ApplicationSessionsConcepts
Système File/Travail1-2Producteur-consommateur, passage de messages, tolérance aux pannes
Magasin avec Réplication3-5Partitionnement, théorème CAP, élection de leader, cohérence
Système de Chat6-7WebSockets, pub/sub, ordonnancement des messages
Système de Consensus8-10Algorithme Raft, réplication de journal, machine à états

Ce que Vous Apprendrez

À la fin de ce cours, vous serez capable de :

  1. Expliquer les concepts des systèmes distribués y compris le théorème CAP, les modèles de cohérence et le consensus
  2. Construire un système de file d'attente fonctionnel avec le modèle producteur-consommateur
  3. Implémenter un magasin clé-valeur répliqué avec élection de leader
  4. Créer un système de chat en temps réel avec messagerie pub/sub
  5. Développer un système basé sur le consensus en utilisant l'algorithme Raft
  6. Déployer tous les systèmes en utilisant Docker Compose sur votre machine locale

Public Cible

Ce cours est conçu pour les développeurs qui :

  • Ont une expérience de base en programmation (fonctions, classes, POO de base)
  • Sont novices en systèmes distribués
  • Veulent comprendre comment fonctionnent les applications distribuées modernes
  • Préfèrent apprendre en pratiquant plutôt que la théorie pure

Prérequis

  • Programmation : À l'aise avec TypeScript ou Python
  • Ligne de Commande : Familiarité de base avec les commandes du terminal
  • Docker : Nous couvrirons la configuration Docker dans la section Configuration Docker

Aucune expérience préalable en systèmes distribués n'est requise !

Progression du Cours

graph TB
    subgraph "Partie I : Fondamentaux"
        A1[Qu'est-ce qu'un SD ?] --> A2[Passage de Messages]
        A2 --> A3[Système de File]
    end

    subgraph "Partie II : Magasin de Données"
        B1[Partitionnement] --> B2[Théorème CAP]
        B2 --> B3[Réplication]
        B3 --> B4[Cohérence]
    end

    subgraph "Partie III : Temps Réel"
        C1[WebSockets] --> C2[Pub/Sub]
        C2 --> C3[Système de Chat]
    end

    subgraph "Partie IV : Consensus"
        D1[Qu'est-ce que le Consensus ?] --> D2[Algorithme Raft]
        D2 --> D3[Élection de Leader]
        D3 --> D4[Réplication de Journal]
        D4 --> D5[Système de Consensus]
    end

    A3 --> B1
    B4 --> C1
    C3 --> D1

Format du Cours

Chaque session de 1,5 heure suit cette structure :

graph LR
    A[Révision<br/>5 min] --> B[Concept<br/>20 min]
    B --> C[Diagramme<br/>10 min]
    C --> D[Démonstration<br/>15 min]
    D --> E[Exercice<br/>25 min]
    E --> F[Test<br/>10 min]
    F --> G[Résumé<br/>5 min]

Composants de Session

  • Explication de Concept : Des explications claires et adaptées aux débutants des concepts fondamentaux
  • Diagrammes Visuels : Des diagrammes Mermaid montrant l'architecture et le flux des données
  • Démonstration en Direct : Procédure pas à pas du code
  • Exercice Pratique : Exercices pratiques pour renforcer l'apprentissage
  • Exécution et Test : Vérifiez que votre implémentation fonctionne correctement

Exemples de Code

Chaque concept inclut des implémentations en TypeScript et Python :

// Exemple TypeScript
interface Message {
  id: string;
  content: string;
}
# Exemple Python
@dataclass
class Message:
    id: str
    content: str

Choisissez le langage avec lequel vous êtes le plus à l'aise, ou apprenez les deux !

Avant de Commencer

1. Configurez Votre Environnement

Suivez le Guide de Configuration Docker pour installer :

  • Docker et Docker Compose
  • Votre langage de programmation préféré (TypeScript ou Python)

2. Vérifiez Votre Installation

docker --version
docker-compose --version

3. Choisissez Votre Langage

Décidez si vous travaillerez avec TypeScript ou Python tout au long du cours. Les deux langages ont des exemples complets pour chaque concept.

Conseils d'Apprentissage

  • Ne vous précipitez pas : Chaque concept s'appuie sur les précédents
  • Exécutez le code : Suivez les exemples dans votre terminal
  • Expérimentez : Modifiez le code et observez ce qui se passe
  • Posez des questions : Utilisez le guide de dépannage quand vous êtes bloqué
  • Construisez en public : Partagez votre progression et apprenez des autres

Ce que Vous Construirez

À la fin de ce cours, vous aurez quatre systèmes distribués fonctionnels :

  1. Système de File - Un système de traitement des tâches tolérant aux pannes
  2. Magasin Répliqué - Un magasin clé-valeur avec élection de leader
  3. Système de Chat - Un système de messagerie en temps réel avec présence
  4. Système de Consensus - Une base de données distribuée basée sur Raft

Tous les systèmes fonctionnent localement en utilisant Docker Compose — aucune infrastructure cloud n'est requise !

Commençons !

Prêt à plonger ? Continuez vers Chapitre 1 : Qu'est-ce qu'un Système Distribué ?

Qu'est-ce qu'un Système Distribué ?

Session 1, Partie 1 - 20 minutes

Objectifs d'Apprentissage

  • Définir ce qu'est un système distribué
  • Identifier les caractéristiques clés des systèmes distribués
  • Comprendre pourquoi les systèmes distribués sont importants
  • Reconnaître les systèmes distribués dans la vie quotidienne

Définition

Un système distribué (distributed system) est une collection d'ordinateurs indépendants qui apparaît à ses utilisateurs comme un système cohérent unique.

graph TB
    subgraph "Utilisateurs Voient"
        Single["Système Unique"]
    end

    subgraph "Réalité"
        N1["Nœud 1"]
        N2["Nœud 2"]
        N3["Nœud 3"]
        N4["Nœud N"]

        N1 <--> N2
        N2 <--> N3
        N3 <--> N4
        N4 <--> N1
    end

    Single -->|"apparaît comme"| N1
    Single -->|"apparaît comme"| N2
    Single -->|"apparaît comme"| N3

Idée Clé

La caractéristique déterminante est l'illusion d'unité — les utilisateurs interagissent avec ce qui semble être un seul système, tandis qu'en coulisses, plusieurs machines travaillent ensemble.

Trois Caractéristiques Clés

Selon Leslie Lamport, un système distribué est :

"Un système dans lequel la défaillance d'un ordinateur dont vous ignoriez même l'existence peut rendre votre propre ordinateur inutilisable."

Cette définition met en évidence trois caractéristiques fondamentales :

1. Concurrence (Plusieurs Choses Se Produisent En Même Temps)

Plusieurs composants s'exécutent simultanément, entraînant des interactions complexes.

sequenceDiagram
    participant U as Utilisateur
    participant A as Serveur A
    participant B as Serveur B
    participant C as Serveur C

    U->>A: Requête
    A->>B: Requête
    A->>C: Mise à jour
    B-->>A: Réponse
    C-->>A: Accusé
    A-->>U: Résultat

2. Pas d'Horloge Globale

Chaque nœud a sa propre horloge. Il n'y a pas de "maintenant" unique dans le système.

graph LR
    A[Horloge A : 10:00:01.123]
    B[Horloge B : 10:00:02.456]
    C[Horloge C : 09:59:59.789]

    A -.->|latence réseau| B
    B -.->|latence réseau| C
    C -.->|latence réseau| A

Implication : Vous ne pouvez pas compter sur les horodatages pour ordonner les événements entre les nœuds. Vous avez besoin d'horloges logiques (nous en reparlerons dans les prochaines sessions !).

3. Défaillance Indépendante

Les composants peuvent tomber en panne indépendamment. Lorsqu'une partie tombe en panne, le reste peut continuer — ou peut devenir inutilisable.

stateDiagram-v2
    [*] --> TousSains: Démarrage Système
    TousSains --> DéfaillancePartielle: Un Nœud Tombe en Panne
    TousSains --> DéfaillanceComplète: Nœuds Critiques Tombent en Panne
    DéfaillancePartielle --> TousSains: Récupération
    DéfaillancePartielle --> DéfaillanceComplète: Défaillance en Cascade
    DéfaillanceComplète --> [*]

Pourquoi des Systèmes Distribués ?

Extensibilité

Mise à l'échelle Verticale (Scale Up) :

  • Ajouter plus de ressources à une seule machine
  • Finit par atteindre les limites matérielles/coût

Mise à l'échelle Horizontale (Scale Out) :

  • Ajouter plus de machines au système
  • Potentiel d'extensibilité pratiquement illimité
graph TB
    subgraph "Mise à l'échelle Verticale"
        Big[Gros Serveur Coûteux<br/>100 000 $]
    end

    subgraph "Mise à l'échelle Horizontale"
        S1[Serveur Standard<br/>1 000 $]
        S2[Serveur Standard<br/>1 000 $]
        S3[Serveur Standard<br/>1 000 $]
        S4[...]
    end

    Big <--> S1
    Big <--> S2
    Big <--> S3

Fiabilité et Disponibilité

Un point unique de défaillance est inacceptable pour les services critiques :

graph TB
    subgraph "Système Unique"
        S[Serveur Unique]
        S -.-> X[❌ Défaillance = Pas de Service]
    end

    subgraph "Système Distribué"
        N1[Nœud 1]
        N2[Nœud 2]
        N3[Nœud 3]

        N1 <--> N2
        N2 <--> N3
        N3 <--> N1

        N1 -.-> X2[❌ Un Tombe en Panne]
        X2 --> OK[✓ Les Autres Continuent]
    end

Latence (Distribution Géographique)

Placer les données plus près des utilisateurs améliore l'expérience :

graph TB
    User[Utilisateur à New York]

    subgraph "Distribution Globale"
        NYC[Centre de Données NYC<br/>latence 10ms]
        LON[Centre de Données Londres<br/>latence 70ms]
        TKY[Centre de Données Tokyo<br/>latence 150ms]
    end

    User --> NYC
    User -.-> LON
    User -.-> TKY

    NYC <--> LON
    LON <--> TKY
    TKY <--> NYC

Exemples de Systèmes Distribués

Exemples Quotidiens

SystèmeDescriptionAvantage
Recherche WebServeurs de requêtes, serveurs d'index, serveurs de cacheRéponses rapides, toujours disponibles
Vidéo en StreamingRéseaux de diffusion de contenu (CDNs)Faible latence, haute qualité
Achats en LigneCatalogue de produits, panier, paiement, inventaireGère les pics de trafic
Réseaux SociauxPublications, commentaires, j'aime, notificationsMises à jour en temps réel

Exemples Techniques

Réplication de Base de Données :

graph LR
    W[Écrire sur le Primaire] --> P[(DB Primaire)]
    P --> R1[(Réplique 1)]
    P --> R2[(Réplique 2)]
    P --> R3[(Réplique 3)]
    R1 --> Read1[Lire depuis la Réplique]
    R2 --> Read2[Lire depuis la Réplique]
    R3 --> Read3[Lire depuis la Réplique]

Répartition de Charge :

graph TB
    Users[Utilisateurs]
    LB[Répartiteur de Charge]

    Users --> LB
    LB --> S1[Serveur 1]
    LB --> S2[Serveur 2]
    LB --> S3[Serveur 3]
    LB --> S4[Serveur N]

Compromis

Les systèmes distribués introduisent de la complexité :

DéfiDescription
Problèmes RéseauNon fiable, latence variable, partitions
ConcurrenceConditions de course, interblocages, coordination
Défaillances PartiellesCertains composants fonctionnent, d'autres non
CohérenceGarder les données synchronisées entre les nœuds

Le Dilemme Fondamental :

"Les avantages de la distribution valent-ils la complexité ajoutée ?"

Pour la plupart des applications modernes, la réponse est oui — c'est pourquoi nous apprenons ceci !

Résumé

Points Clés à Retenir

  1. Systèmes distribués = plusieurs ordinateurs agissant comme un seul
  2. Trois caractéristiques : concurrence, pas d'horloge globale, défaillance indépendante
  3. Avantages : extensibilité, fiabilité, latence réduite
  4. Coûts : complexité, problèmes réseau, défis de cohérence

Vérifiez Votre Compréhension

  • Pouvez-vous expliquer pourquoi il n'y a pas d'horloge globale dans un système distribué ?
  • Donnez un exemple de système distribué que vous utilisez quotidiennement
  • Pourquoi la défaillance indépendante rend-elle les systèmes distribués plus difficiles à construire ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Suite

Maintenant que nous comprenons ce que sont les systèmes distribués, explorons comment ils communiquent : Passage de Messages

Passage de Messages

Session 1, Partie 2 - 25 minutes

Objectifs d'Apprentissage

  • Comprendre le passage de messages comme modèle fondamental dans les systèmes distribués
  • Distinguer entre la messagerie synchrone et asynchrone
  • Apprendre les différentes garanties de livraison de messages
  • Implémenter le passage de messages de base en TypeScript et Python

Qu'est-ce que le Passage de Messages ?

Dans les systèmes distribués, le passage de messages (message passing) est la façon dont les nœuds communiquent. Au lieu de la mémoire partagée ou des appels de fonction directs, les composants s'envoient des messages sur le réseau.

graph LR
    A[Nœud A]
    B[Nœud B]
    M[Message]

    A -->|envoyer| M
    M -->|réseau| B
    B -->|traiter| M

Idée Clé

"Dans les systèmes distribués, la communication n'est pas un appel de fonction — c'est une requête envoyée sur un réseau non fiable."

Ce simple fait a des implications profondes sur tout ce que nous construisons.

Synchrone vs Asynchrone

Messagerie Synchrone (Requête-Réponse)

L'expéditeur attend une réponse avant de continuer.

sequenceDiagram
    participant C as Client
    participant S as Serveur

    C->>S: Requête
    Note over C: En attente...
    S-->>C: Réponse
    Note over C: Continuer

Caractéristiques :

  • Simple à comprendre et à implémenter
  • L'appelant est bloqué pendant l'appel
  • Gestion des erreurs plus facile (retour immédiat)
  • Peut entraîner de mauvaises performances et des défaillances en cascade

Messagerie Asynchrone (Fire-and-Forget)

L'expéditeur continue sans attendre de réponse.

sequenceDiagram
    participant P as Producteur
    participant Q as File
    participant W as Worker

    P->>Q: Envoyer Message
    Note over P: Continuer immédiatement

    Q->>W: Traiter Plus Tard
    Note over W: Travail en cours...
    W-->>P: Résultat (optionnel)

Caractéristiques :

  • Non bloquant, meilleur débit
  • Gestion des erreurs plus complexe
  • Nécessite des ID de corrélation pour suivre les requêtes
  • Permet un couplage souple entre les composants

Garanties de Livraison des Messages

Trois Sémantiques de Livraison

graph TB
    subgraph "Au Plus Une Fois"
        A1[Envoyer] --> A2[Peut être perdu]
        A2 --> A3[Jamais dupliqué]
    end

    subgraph "Au Moins Une Fois"
        B1[Envoyer] --> B2[Réessayer jusqu'à accusé]
        B2 --> B3[Peut être dupliqué]
    end

    subgraph "Exactement Une Fois"
        C1[Envoyer] --> C2[Déduplication]
        C2 --> C3[Livraison parfaite]
    end

Comparaison

GarantieDescriptionCoûtCas d'Usage
Au Plus Une FoisLe message peut être perdu, jamais dupliquéLe plus basJournaux, métriques, données non critiques
Au Moins Une FoisLe message garanti d'arriver, peut être dupliquéMoyenNotifications, files de tâches
Exactement Une FoisLivraison parfaite, pas de doublonsLe plus élevéTransactions financières, paiements

Le Problème des Deux Généraux

Une preuve classique que la communication parfaite est impossible dans les réseaux non fiables :

graph LR
    A[Général A<br/>Ville 1]
    B[Général B<br/>Ville 2]

    A -->|"Attaque à 20h ?"| B
    B -->|"Acc : reçu"| A
    A -->|"Acc : accusé reçu"| B
    B -->|"Acc : accusé de l'accusé reçu"| A

    Note[A : messages infinis nécessaires]

Implication : Vous ne pouvez jamais être certain à 100 % qu'un message a été reçu sans accusés infinis.

En pratique, nous acceptons l'incertitude et concevons des systèmes qui la tolèrent.

Modèles d'Architecture

Communication Directe

graph LR
    A[Service A] --> B[Service B]
    A --> C[Service C]
    B --> D[Service D]
    C --> D
  • Simple, direct
  • Couplage fort
  • Difficile à faire évoluer indépendamment

File de Messages (Communication Indirecte)

graph TB
    P[Producteur 1] --> Q[File de Messages]
    P2[Producteur 2] --> Q
    P3[Producteur N] --> Q

    Q --> W1[Worker 1]
    Q --> W2[Worker 2]
    Q --> W3[Worker N]
  • Couplage souple
  • Facile à faire évoluer
  • Met en tampon les requêtes pendant les pics de trafic
  • Permet les nouvelles tentatives et la gestion des erreurs

Exemples d'Implémentation

TypeScript : HTTP (Synchrone)

// server.ts
import http from 'http';

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/message') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      const message = JSON.parse(body);
      console.log('Received:', message);

      // Renvoyer la réponse (synchrone)
      res.writeHead(200);
      res.end(JSON.stringify({ status: 'processed', id: message.id }));
    });
  }
});

server.listen(3000, () => console.log('Server on :3000'));

// client.ts
import http from 'http';

function sendMessage(data: any): Promise<any> {
  return new Promise((resolve, reject) => {
    const postData = JSON.stringify(data);

    const options = {
      hostname: 'localhost',
      port: 3000,
      method: 'POST',
      path: '/message',
      headers: { 'Content-Type': 'application/json' }
    };

    const req = http.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => resolve(JSON.parse(body)));
    });

    req.on('error', reject);
    req.write(postData);
    req.end();
  });
}

// Usage : attend la réponse
sendMessage({ id: '1', content: 'Hello' })
  .then(response => console.log('Got:', response));

Python : HTTP (Synchrone)

# server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

class MessageHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == '/message':
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)
            message = json.loads(post_data.decode())

            print(f"Received: {message}")

            # Renvoyer la réponse (synchrone)
            response = json.dumps({'status': 'processed', 'id': message['id']})
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response.encode())

server = HTTPServer(('localhost', 3000), MessageHandler)
print("Server on :3000")
server.serve_forever()

# client.py
import requests
import json

def send_message(data):
    # Synchrone : attend la réponse
    response = requests.post(
        'http://localhost:3000/message',
        json=data
    )
    return response.json()

# Usage
result = send_message({'id': '1', 'content': 'Hello'})
print(f"Got: {result}")

TypeScript : File Simple (Asynchrone)

// queue.ts
interface Message {
  id: string;
  data: any;
  timestamp: number;
}

class MessageQueue {
  private messages: Message[] = [];
  private handlers: Map<string, (msg: Message) => void> = new Map();

  publish(topic: string, data: any): string {
    const message: Message = {
      id: `${Date.now()}-${Math.random()}`,
      data,
      timestamp: Date.now()
    };

    this.messages.push(message);
    console.log(`Published to ${topic}:`, message.id);

    // Fire and forget - ne pas attendre le traitement
    setImmediate(() => this.process(topic, message));

    return message.id;
  }

  subscribe(topic: string, handler: (msg: Message) => void) {
    this.handlers.set(topic, handler);
  }

  private process(topic: string, message: Message) {
    const handler = this.handlers.get(topic);
    if (handler) {
      // Traiter de manière asynchrone - l'appelant n'attend pas
      handler(message);
    }
  }
}

// Usage
const queue = new MessageQueue();

queue.subscribe('tasks', (msg) => {
  console.log(`Processing task ${msg.id}:`, msg.data);
  // Simuler un travail asynchrone
  setTimeout(() => console.log(`Task ${msg.id} complete`), 1000);
});

// Publish retourne immédiatement - n'attend pas le traitement
const taskId = queue.publish('tasks', { type: 'email', to: 'user@example.com' });
console.log(`Task ${taskId} queued (not yet processed)`);

Python : File Simple (Asynchrone)

# queue.py
import time
import threading
from dataclasses import dataclass
from typing import Callable, Dict, Any
import uuid

@dataclass
class Message:
    id: str
    data: Any
    timestamp: float

class MessageQueue:
    def __init__(self):
        self.messages = []
        self.handlers: Dict[str, Callable[[Message], None]] = {}
        self.lock = threading.Lock()

    def publish(self, topic: str, data: Any) -> str:
        message = Message(
            id=f"{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}",
            data=data,
            timestamp=time.time()
        )

        with self.lock:
            self.messages.append(message)

        print(f"Published to {topic}: {message.id}")

        # Fire and forget - ne pas attendre le traitement
        threading.Thread(
            target=self._process,
            args=(topic, message),
            daemon=True
        ).start()

        return message.id

    def subscribe(self, topic: str, handler: Callable[[Message], None]):
        self.handlers[topic] = handler

    def _process(self, topic: str, message: Message):
        handler = self.handlers.get(topic)
        if handler:
            # Traiter de manière asynchrone - l'appelant n'attend pas
            handler(message)

# Usage
queue = MessageQueue()

def handle_task(msg: Message):
    print(f"Processing task {msg.id}: {msg.data}")
    # Simuler un travail asynchrone
    time.sleep(1)
    print(f"Task {msg.id} complete")

queue.subscribe('tasks', handle_task)

# Publish retourne immédiatement - n'attend pas le traitement
task_id = queue.publish('tasks', {'type': 'email', 'to': 'user@example.com'})
print(f"Task {task_id} queued (not yet processed)")

# Garder le thread principal en vie pour voir le traitement
time.sleep(2)

Modèles de Messages Courants

Requête-Réponse

// Appeler et attendre la réponse
const answer = await ask(question);

Fire-and-Forget

// Envoyer et continuer
notify(user);

Publier-S'Abonner

// Plusieurs récepteurs, un expéditeur
broker.publish('events', data);

Requête-Réponse (avec Corrélation)

// Envoyer la requête, obtenir la réponse plus tard
const replyTo = createReplyQueue();
broker.send(request, { replyTo });
// ... plus tard
const reply = await replyTo.receive();

Gestion des Erreurs

Le passage de messages sur les réseaux n'est pas fiable. Problèmes courants :

ErreurCauseStratégie de Gestion
Délai d'attentePas de réponse, réseau lentRéessayer avec attente progressive
Connexion RefuséeService indisponibleDisjoncteur, mettre en file pour plus tard
Message PerduDéfaillance du réseauAccusés de réception, nouvelles tentatives
DuplicationNouvelle tentative après accusé lentOpérations idempotentes

Modèle de Nouvelle Tentative

async function sendMessageWithRetry(
  message: any,
  maxRetries = 3
): Promise<any> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await sendMessage(message);
    } catch (error) {
      if (attempt === maxRetries) throw error;

      // Attente exponentielle : 100ms, 200ms, 400ms
      const delay = 100 * Math.pow(2, attempt - 1);
      await new Promise(r => setTimeout(r, delay));
      console.log(`Retry ${attempt}/${maxRetries}`);
    }
  }
}

Résumé

Points Clés à Retenir

  1. Passage de messages = comment les systèmes distribués communiquent
  2. Synchrone = attendre la réponse ; Asynchrone = fire and forget
  3. Garanties de livraison : au-plus-une-fois, au-moins-une-fois, exactement-une-fois
  4. Le réseau n'est pas fiable - concevez pour les défaillances et les nouvelles tentatives
  5. Choisissez le bon modèle pour votre cas d'usage

Vérifiez Votre Compréhension

  • Quand utiliseriez-vous la messagerie synchrone vs asynchrone ?
  • Quelle est la différence entre au-moins-une-fois et exactement-une-fois ?
  • Pourquoi la communication parfaite est-elle impossible dans les systèmes distribués ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Suite

Appliquons maintenant le passage de messages pour construire notre premier système distribué : Implémentation du Système de File

Implémentation du Système de File

Session 2 - Session complète (90 minutes)

Objectifs d'Apprentissage

  • Comprendre le modèle producteur-consommateur
  • Construire un système de file fonctionnel avec des workers concurrents
  • Implémenter la tolérance aux pannes avec une logique de nouvelle tentative
  • Déployer et tester le système avec Docker Compose

Le Modèle Producteur-Consommateur

Le modèle producteur-consommateur (producer-consumer pattern) est un modèle fondamental des systèmes distribués où :

  • Les Producteurs créent et envoient des tâches à une file
  • La File met en tampon les tâches entre les producteurs et les consommateurs
  • Les Workers (consommateurs) traitent les tâches de la file
graph TB
    subgraph "Producteurs"
        P1[Producteur 1<br/>Serveur API]
        P2[Producteur 2<br/>Planificateur]
        P3[Producteur N<br/>Webhook]
    end

    subgraph "File"
        Q[File de Messages<br/>Tampon de Tâches]
    end

    subgraph "Workers"
        W1[Worker 1<br/>Processus]
        W2[Worker 2<br/>Processus]
        W3[Worker 3<br/>Processus]
    end

    P1 --> Q
    P2 --> Q
    P3 --> Q
    Q --> W1
    Q --> W2
    Q --> W3

    style Q fill:#f9f,stroke:#333,stroke-width:4px

Avantages Clés

AvantageExplication
DécouplageLes producteurs n'ont pas besoin de connaître les workers
Mise en TamponLa file gère les pics de trafic
ExtensibilitéAjoutez/supprimez des workers indépendamment
FiabilitéLes tâches persistent si les workers tombent en panne
Nouvelle TentativeLes tâches échouées peuvent être remises en file

Architecture du Système

Vue Complète du Système

sequenceDiagram
    participant C as Client
    participant P as Producteur
    participant Q as File
    participant W as Worker
    participant DB as Magasin de Résultats

    C->>P: HTTP POST /task
    P->>Q: Mettre en File Tâche
    Q-->>P: ID de Tâche
    P-->>C: 202 Accepté

    Note over Q,W: Traitement Asynchrone

    Q->>W: Récupérer Tâche
    W->>W: Traiter Tâche
    W->>DB: Sauvegarder Résultat

    W->>Q: Ack (Succès)
    Q->>Q: Supprimer Tâche

Cycle de Vie d'une Tâche

stateDiagram-v2
    [*] --> EnAttente: Création par le Producteur
    EnAttente --> EnCours: Récupération par le Worker
    EnCours --> Terminé: Succès
    EnCours --> Échoué: Erreur
    EnCours --> EnAttente: Nouvelle Tentative
    Échoué --> EnAttente: Nombre max de nouvelles tentatives non atteint
    Échoué --> LettreMorte: Nombre max de nouvelles tentatives atteint
    Terminé --> [*]
    LettreMorte --> [*]

Implémentation

Modèles de Données

Définition de Tâche :

interface Task {
  id: string;
  type: string;           // 'email', 'image', 'report', etc.
  payload: any;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  createdAt: number;
  retries: number;
  maxRetries: number;
  result?: any;
  error?: string;
}
from dataclasses import dataclass, field
from typing import Any, Optional

@dataclass
class Task:
    id: str
    type: str  # 'email', 'image', 'report', etc.
    payload: Any
    status: str = 'pending'  # pending, processing, completed, failed
    created_at: float = field(default_factory=time.time)
    retries: int = 0
    max_retries: int = 3
    result: Optional[Any] = None
    error: Optional[str] = None

Implémentation TypeScript

Structure du Projet

queue-system-ts/
├── package.json
├── docker-compose.yml
├── src/
│   ├── queue.ts          # Implémentation de la file
│   ├── producer.ts       # API du producteur
│   ├── worker.ts         # Implémentation du worker
│   └── types.ts          # Définitions de types
└── Dockerfile

Code TypeScript Complet

queue-system-ts/src/types.ts

export interface Task {
  id: string;
  type: string;
  payload: any;
  status: 'pending' | 'processing' | 'completed' | 'failed';
  createdAt: number;
  retries: number;
  maxRetries: number;
  result?: any;
  error?: string;
}

export interface QueueMessage {
  task: Task;
  timestamp: number;
}

queue-system-ts/src/queue.ts

import { Task, QueueMessage } from './types';

export class Queue {
  private pending: Task[] = [];
  private processing: Map<string, Task> = new Map();
  private completed: Task[] = [];
  private failed: Task[] = [];

  // Mettre en file une nouvelle tâche
  enqueue(type: string, payload: any): string {
    const task: Task = {
      id: this.generateId(),
      type,
      payload,
      status: 'pending',
      createdAt: Date.now(),
      retries: 0,
      maxRetries: 3
    };

    this.pending.push(task);
    console.log(`[Queue] Enqueued task ${task.id} (${type})`);
    return task.id;
  }

  // Obtenir la prochaine tâche en attente (pour les workers)
  dequeue(): Task | null {
    if (this.pending.length === 0) return null;

    const task = this.pending.shift()!;
    task.status = 'processing';
    this.processing.set(task.id, task);

    console.log(`[Queue] Dequeued task ${task.id}`);
    return task;
  }

  // Marquer la tâche comme terminée
  complete(taskId: string, result?: any): void {
    const task = this.processing.get(taskId);
    if (!task) return;

    task.status = 'completed';
    task.result = result;
    this.processing.delete(taskId);
    this.completed.push(task);

    console.log(`[Queue] Completed task ${taskId}`);
  }

  // Marquer la tâche comme échouée (réessayer si possible)
  fail(taskId: string, error: string): void {
    const task = this.processing.get(taskId);
    if (!task) return;

    task.retries++;
    task.error = error;

    if (task.retries >= task.maxRetries) {
      task.status = 'failed';
      this.processing.delete(taskId);
      this.failed.push(task);
      console.log(`[Queue] Task ${taskId} failed permanently after ${task.retries} retries`);
    } else {
      task.status = 'pending';
      this.processing.delete(taskId);
      this.pending.push(task);
      console.log(`[Queue] Task ${taskId} failed, retrying (${task.retries}/${task.maxRetries})`);
    }
  }

  // Obtenir les statistiques de la file
  getStats() {
    return {
      pending: this.pending.length,
      processing: this.processing.size,
      completed: this.completed.length,
      failed: this.failed.length
    };
  }

  private generateId(): string {
    return `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

queue-system-ts/src/producer.ts

import http from 'http';
import { Queue } from './queue';

const queue = new Queue();

const server = http.createServer((req, res) => {
  if (req.method === 'POST' && req.url === '/task') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const { type, payload } = JSON.parse(body);

        if (!type || !payload) {
          res.writeHead(400);
          res.end(JSON.stringify({ error: 'type and payload required' }));
          return;
        }

        const taskId = queue.enqueue(type, payload);

        res.writeHead(202); // Accepted
        res.end(JSON.stringify({
          taskId,
          message: 'Task enqueued',
          stats: queue.getStats()
        }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });
  } else if (req.method === 'GET' && req.url === '/stats') {
    res.writeHead(200);
    res.end(JSON.stringify(queue.getStats()));
  } else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: 'Not found' }));
  }
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Producer API listening on port ${PORT}`);
});

export { queue };

queue-system-ts/src/worker.ts

import http from 'http';
import { Queue, Task } from './types';

// Simuler le traitement de tâches
async function processTask(task: Task): Promise<any> {
  console.log(`[Worker] Processing task ${task.id} (${task.type})`);

  // Simuler le travail
  await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));

  // Simuler des échocs occasionnels (20% de chance)
  if (Math.random() < 0.2) {
    throw new Error('Random processing error');
  }

  // Traiter en fonction du type de tâche
  switch (task.type) {
    case 'email':
      return { sent: true, to: task.payload.to };
    case 'image':
      return { processed: true, url: task.payload.url };
    case 'report':
      return { generated: true, format: 'pdf' };
    default:
      return { result: 'processed' };
  }
}

class Worker {
  private id: string;
  private queueUrl: string;
  private running: boolean = false;

  constructor(id: string, queueUrl: string) {
    this.id = id;
    this.queueUrl = queueUrl;
  }

  async start(): Promise<void> {
    this.running = true;
    console.log(`[Worker ${this.id}] Started`);

    while (this.running) {
      try {
        await this.processNextTask();
      } catch (error) {
        console.error(`[Worker ${this.id}] Error:`, error);
        await this.sleep(1000); // Attendre avant de réessayer
      }
    }
  }

  private async processNextTask(): Promise<void> {
    // Récupérer la tâche de la file
    const task = await this.fetchTask();
    if (!task) {
      await this.sleep(1000); // Pas de tâche, attendre
      return;
    }

    try {
      // Traiter la tâche
      const result = await processTask(task);

      // Marquer comme terminée
      await this.completeTask(task.id, result);
    } catch (error: any) {
      // Marquer comme échouée
      await this.failTask(task.id, error.message);
    }
  }

  private async fetchTask(): Promise<Task | null> {
    return new Promise((resolve, reject) => {
      http.get(`${this.queueUrl}/dequeue`, (res) => {
        let body = '';
        res.on('data', chunk => body += chunk);
        res.on('end', () => {
          if (res.statusCode === 204) {
            resolve(null); // Aucune tâche disponible
          } else if (res.statusCode === 200) {
            resolve(JSON.parse(body));
          } else {
            reject(new Error(`Unexpected status: ${res.statusCode}`));
          }
        });
      }).on('error', reject);
    });
  }

  private async completeTask(taskId: string, result: any): Promise<void> {
    return new Promise((resolve, reject) => {
      const data = JSON.stringify({ result });
      http.request({
        hostname: 'localhost',
        port: 3000,
        path: `/complete/${taskId}`,
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
      }, (res) => {
        if (res.statusCode === 200) {
          resolve();
        } else {
          reject(new Error(`Failed to complete task: ${res.statusCode}`));
        }
      }).on('error', reject).end(data);
    });
  }

  private async failTask(taskId: string, error: string): Promise<void> {
    return new Promise((resolve, reject) => {
      const data = JSON.stringify({ error });
      http.request({
        hostname: 'localhost',
        port: 3000,
        path: `/fail/${taskId}`,
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
      }, (res) => {
        if (res.statusCode === 200) {
          resolve();
        } else {
          reject(new Error(`Failed to fail task: ${res.statusCode}`));
        }
      }).on('error', reject).end(data);
    });
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  stop(): void {
    this.running = false;
  }
}

// Démarrer le worker
const workerId = process.env.WORKER_ID || 'worker-1';
const worker = new Worker(workerId, 'http://localhost:3000');
worker.start();

Implémentation Python

Structure du Projet

queue-system-py/
├── requirements.txt
├── docker-compose.yml
├── src/
│   ├── queue.py          # Implémentation de la file
│   ├── producer.py       # API du producteur
│   └── worker.py         # Implémentation du worker
└── Dockerfile

Code Python Complet

queue-system-py/src/queue.py

import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional, List, Dict
from enum import Enum

class TaskStatus(Enum):
    PENDING = 'pending'
    PROCESSING = 'processing'
    COMPLETED = 'completed'
    FAILED = 'failed'

@dataclass
class Task:
    id: str
    type: str
    payload: Any
    status: str = TaskStatus.PENDING.value
    created_at: float = field(default_factory=time.time)
    retries: int = 0
    max_retries: int = 3
    result: Optional[Any] = None
    error: Optional[str] = None

class Queue:
    def __init__(self):
        self.pending: List[Task] = []
        self.processing: Dict[str, Task] = {}
        self.completed: List[Task] = []
        self.failed: List[Task] = []

    def enqueue(self, task_type: str, payload: Any) -> str:
        """Mettre en file une nouvelle tâche."""
        task = Task(
            id=f"task-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}",
            type=task_type,
            payload=payload
        )
        self.pending.append(task)
        print(f"[Queue] Enqueued task {task.id} ({task_type})")
        return task.id

    def dequeue(self) -> Optional[Task]:
        """Obtenir la prochaine tâche en attente."""
        if not self.pending:
            return None

        task = self.pending.pop(0)
        task.status = TaskStatus.PROCESSING.value
        self.processing[task.id] = task
        print(f"[Queue] Dequeued task {task.id}")
        return task

    def complete(self, task_id: str, result: Any = None) -> None:
        """Marquer la tâche comme terminée."""
        task = self.processing.pop(task_id, None)
        if not task:
            return

        task.status = TaskStatus.COMPLETED.value
        task.result = result
        self.completed.append(task)
        print(f"[Queue] Completed task {task_id}")

    def fail(self, task_id: str, error: str) -> None:
        """Marquer la tâche comme échouée (réessayer si possible)."""
        task = self.processing.pop(task_id, None)
        if not task:
            return

        task.retries += 1
        task.error = error

        if task.retries >= task.max_retries:
            task.status = TaskStatus.FAILED.value
            self.failed.append(task)
            print(f"[Queue] Task {task_id} failed permanently after {task.retries} retries")
        else:
            task.status = TaskStatus.PENDING.value
            self.pending.append(task)
            print(f"[Queue] Task {task_id} failed, retrying ({task.retries}/{task.max_retries})")

    def get_stats(self) -> Dict[str, int]:
        """Obtenir les statistiques de la file."""
        return {
            'pending': len(self.pending),
            'processing': len(self.processing),
            'completed': len(self.completed),
            'failed': len(self.failed)
        }

queue-system-py/src/producer.py

from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from queue import Queue

queue = Queue()

class ProducerHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path == '/task':
            content_length = int(self.headers['Content-Length'])
            post_data = self.rfile.read(content_length)

            try:
                data = json.loads(post_data.decode())
                task_type = data.get('type')
                payload = data.get('payload')

                if not task_type or not payload:
                    self.send_error(400, 'type and payload required')
                    return

                task_id = queue.enqueue(task_type, payload)

                response = json.dumps({
                    'taskId': task_id,
                    'message': 'Task enqueued',
                    'stats': queue.get_stats()
                })

                self.send_response(202)  # Accepted
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(response.encode())

            except json.JSONDecodeError:
                self.send_error(400, 'Invalid JSON')

    def do_GET(self):
        if self.path == '/stats':
            response = json.dumps(queue.get_stats())
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(response.encode())

    def log_message(self, format, *args):
        pass  # Supprimer la journalisation par défaut

if __name__ == '__main__':
    import os
    port = int(os.environ.get('PORT', 3000))
    server = HTTPServer(('0.0.0.0', port), ProducerHandler)
    print(f"Producer API listening on port {port}")
    server.serve_forever()

queue-system-py/src/worker.py

import os
import time
import random
import requests
from typing import Optional, Dict, Any
from queue import Task

# Simuler le traitement de tâches
def process_task(task: Task) -> Any:
    print(f"[Worker] Processing task {task.id} ({task.type})")

    # Simuler le travail
    time.sleep(1 + random.random() * 2)

    # Simuler des échecs occasionnels (20% de chance)
    if random.random() < 0.2:
        raise Exception('Random processing error')

    # Traiter en fonction du type de tâche
    if task.type == 'email':
        return {'sent': True, 'to': task.payload.get('to')}
    elif task.type == 'image':
        return {'processed': True, 'url': task.payload.get('url')}
    elif task.type == 'report':
        return {'generated': True, 'format': 'pdf'}
    else:
        return {'result': 'processed'}

class Worker:
    def __init__(self, worker_id: str, queue_url: str):
        self.id = worker_id
        self.queue_url = queue_url
        self.running = False

    def start(self):
        """Démarrer la boucle du worker."""
        self.running = True
        print(f"[Worker {self.id}] Started")

        while self.running:
            try:
                self.process_next_task()
            except Exception as e:
                print(f"[Worker {self.id}] Error: {e}")
                time.sleep(1)

    def process_next_task(self):
        """Récupérer et traiter la prochaine tâche."""
        task = self.fetch_task()
        if not task:
            time.sleep(1)  # Pas de tâche, attendre
            return

        try:
            result = process_task(task)
            self.complete_task(task['id'], result)
        except Exception as e:
            self.fail_task(task['id'], str(e))

    def fetch_task(self) -> Optional[Dict]:
        """Récupérer la prochaine tâche de la file."""
        try:
            response = requests.get(f"{self.queue_url}/dequeue", timeout=5)
            if response.status_code == 204:
                return None  # Aucune tâche
            return response.json()
        except requests.RequestException:
            return None

    def complete_task(self, task_id: str, result: Any):
        """Marquer la tâche comme terminée."""
        requests.post(
            f"{self.queue_url}/complete/{task_id}",
            json={'result': result},
            timeout=5
        )

    def fail_task(self, task_id: str, error: str):
        """Marquer la tâche comme échouée."""
        requests.post(
            f"{self.queue_url}/fail/{task_id}",
            json={'error': error},
            timeout=5
        )

    def stop(self):
        """Arrêter le worker."""
        self.running = False

if __name__ == '__main__':
    worker_id = os.environ.get('WORKER_ID', 'worker-1')
    queue_url = os.environ.get('QUEUE_URL', 'http://localhost:3000')
    worker = Worker(worker_id, queue_url)
    worker.start()

Configuration Docker Compose

Version TypeScript (docker-compose.yml)

version: '3.8'

services:
  producer:
    build: ./src
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
    volumes:
      - ./src:/app/src
    command: npm run start:producer

  worker-1:
    build: ./src
    environment:
      - WORKER_ID=worker-1
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: npm run start:worker

  worker-2:
    build: ./src
    environment:
      - WORKER_ID=worker-2
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: npm run start:worker

  worker-3:
    build: ./src
    environment:
      - WORKER_ID=worker-3
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: npm run start:worker

Dockerfile TypeScript

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

CMD ["npm", "run", "start:producer"]

Version Python (docker-compose.yml)

version: '3.8'

services:
  producer:
    build: ./src
    ports:
      - "3000:3000"
    environment:
      - PORT=3000
    volumes:
      - ./src:/app/src
    command: python src/producer.py

  worker-1:
    build: ./src
    environment:
      - WORKER_ID=worker-1
      - QUEUE_URL=http://producer:3000
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: python src/worker.py

  worker-2:
    build: ./src
    environment:
      - WORKER_ID=worker-2
      - QUEUE_URL=http://producer:3000
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: python src/worker.py

  worker-3:
    build: ./src
    environment:
      - WORKER_ID=worker-3
      - QUEUE_URL=http://producer:3000
    depends_on:
      - producer
    volumes:
      - ./src:/app/src
    command: python src/worker.py

Dockerfile Python

FROM python:3.11-alpine

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "src/producer.py"]

Exécution de l'Exemple

Étape 1 : Démarrer le Système

cd examples/01-queue
docker-compose up --build

Vous devriez voir une sortie comme :

producer      | Producer API listening on port 3000
worker-1      | [Worker worker-1] Started
worker-2      | [Worker worker-2] Started
worker-3      | [Worker worker-3] Started

Étape 2 : Soumettre des Tâches

Ouvrez un nouveau terminal et soumettez quelques tâches :

# Soumettre une tâche email
curl -X POST http://localhost:3000/task \
  -H "Content-Type: application/json" \
  -d '{"type": "email", "payload": {"to": "user@example.com", "subject": "Hello"}}'

# Soumettre une tâche de traitement d'image
curl -X POST http://localhost:3000/task \
  -H "Content-Type: application/json" \
  -d '{"type": "image", "payload": {"url": "https://example.com/image.jpg"}}'

# Soumettre plusieurs tâches
for i in {1..10}; do
  curl -X POST http://localhost:3000/task \
    -H "Content-Type: application/json" \
    -d "{\"type\": \"report\", \"payload\": {\"id\": $i}}"
done

Étape 3 : Observer le Traitement

Dans les journaux Docker, vous verrez :

worker-2      | [Queue] Dequeued task task-1234567890-abc123
worker-2      | [Worker] Processing task task-1234567890-abc123 (report)
worker-2      | [Queue] Completed task task-1234567890-abc123

Étape 4 : Vérifier les Statistiques

curl http://localhost:3000/stats

Réponse :

{
  "pending": 5,
  "processing": 3,
  "completed": 12,
  "failed": 0
}

Étape 5 : Tester la Tolérance aux Pannes

Arrêtez un worker :

docker-compose stop worker-1

Les tâches continuent d'être traitées par les workers restants. La file gère automatiquement la redistribution de la charge.

Exercices

Exercice 1 : Ajouter le Support des Priorités

Modifiez la file pour prendre en charge les tâches de priorité haute/normale/basse :

  1. Ajoutez un champ priority au modèle de Tâche
  2. Modifiez enqueue() pour trier les tâches en attente par priorité
  3. Testez avec des tâches de priorité mixte

Exercice 2 : Implémenter une File des Lettres Mortes

Créez une file séparée pour les tâches définitivement échouées :

  1. Ajoutez une file dead_letter pour stocker les tâches échouées
  2. Ajoutez un point de terminaison API pour inspecter/réessayer les tâches de lettres mortes
  3. Journalisez les tâches échouées dans un fichier pour inspection manuelle

Exercice 3 : Ajouter la Planification de Tâches

Implémentez l'exécution différée des tâches :

  1. Ajoutez un horodatage executeAt aux tâches
  2. Modifiez les workers pour ignorer les tâches planifiées dans le futur
  3. Utilisez une minuterie/planificateur pour déplacer les tâches planifiées vers la file en attente

Résumé

Points Clés à Retenir

  1. Modèle producteur-consommateur découple la création de tâches du traitement
  2. Les files mettent en tampon les tâches et gèrent les pics de trafic
  3. Les workers évoluent indépendamment des producteurs
  4. La logique de nouvelle tentative fournit une tolérance aux pannes
  5. Docker Compose permet un déploiement local facile

Vérifiez Votre Compréhension

  • Comment la file gère-t-elle les défaillances de workers ?
  • Que se passe-t-il lorsqu'une tâche échoue et que le nombre max de nouvelles tentatives est atteint ?
  • Pourquoi la file est-elle utile pour gérer les pics de trafic ?
  • Comment ajouteriez-vous un nouveau type de worker (par exemple, un worker qui traite uniquement les emails) ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Suite

Maintenant que nous avons construit un système de file, explorons comment partitionner les données sur plusieurs nœuds : Partitionnement des Données

Partitionnement des Données

Session 3, Partie 1 - 25 minutes

Objectifs d'Apprentissage

  • Comprendre ce qu'est le partitionnement des données (sharding)
  • Comparer le partitionnement basé sur le hachage vs par plage
  • Apprendre comment le partitionnement affecte les performances des requêtes
  • Reconnaître les compromis des différentes stratégies de partitionnement

Qu'est-ce que le Partitionnement ?

Le partitionnement des données (aussi appelé sharding) est le processus de répartition de vos données sur plusieurs nœuds basé sur une clé de partitionnement. Chaque nœud contient un sous-ensemble des données totales.

graph TB
    subgraph "Vue de l'Application"
        App["Votre Application"]
        Data[("Toutes les Données")]
        App --> Data
    end

    subgraph "Réalité : Stockage Partitionné"
        Node1["Nœud 1<br/>Clés : user_1<br/>user_4<br/>user_7"]
        Node2["Nœud 2<br/>Clés : user_2<br/>user_5<br/>user_8"]
        Node3["Nœud 3<br/>Clés : user_3<br/>user_6<br/>user_9"]
    end

    App -->|"lecture/écriture"| Node1
    App -->|"lecture/écriture"| Node2
    App -->|"lecture/écriture"| Node3

    style Node1 fill:#e1f5fe
    style Node2 fill:#e1f5fe
    style Node3 fill:#e1f5fe

Pourquoi Partitionner les Données ?

AvantageDescription
Mise à l'échelleStocker plus de données que ce qui tient sur une seule machine
PerformanceDistribuer la charge sur plusieurs nœuds
DisponibilitéLa défaillance d'une partition n'affecte pas les autres

Le Défi du Partitionnement

La question clé est : Comment décider quelles données vont sur quel nœud ?

graph LR
    Key["user:12345"] --> Router{Fonction de<br/>Partitionnement}
    Router -->|"hash(clé) % N"| N1[Nœud 1]
    Router --> N2[Nœud 2]
    Router --> N3[Nœud 3]

    style Router fill:#ff9,stroke:#333,stroke-width:3px

Stratégies de Partitionnement

1. Partitionnement Basé sur le Hachage

Appliquer une fonction de hachage à la clé, puis modulo le nombre de nœuds :

nœud = hash(clé) % nombre_de_nœuds
graph TB
    subgraph "Partitionnement Basé sur le Hachage (3 nœuds)"
        Key1["user:alice"] --> H1["hash() % 3"]
        Key2["user:bob"] --> H2["hash() % 3"]
        Key3["user:carol"] --> H3["hash() % 3"]

        H1 -->|"= 1"| N1[Nœud 1]
        H2 -->|"= 2"| N2[Nœud 2]
        H3 -->|"= 0"| N0[Nœud 0]

        style N1 fill:#c8e6c9
        style N2 fill:#c8e6c9
        style N0 fill:#c8e6c9
    end

Exemple TypeScript :

function getNode(key: string, totalNodes: number): number {
    // Fonction de hachage simple
    let hash = 0;
    for (let i = 0; i < key.length; i++) {
        hash = ((hash << 5) - hash) + key.charCodeAt(i);
        hash = hash & hash; // Convertir en entier 32bit
    }
    return Math.abs(hash) % totalNodes;
}

// Exemples
console.log(getNode('user:alice', 3));  // => 1
console.log(getNode('user:bob', 3));    // => 2
console.log(getNode('user:carol', 3));  // => 0

Exemple Python :

def get_node(key: str, total_nodes: int) -> int:
    """Déterminer quel nœud doit stocker cette clé."""
    hash_value = hash(key)  # Fonction de hachage intégrée
    return abs(hash_value) % total_nodes

# Exemples
print(get_node('user:alice', 3))   # => 1
print(get_node('user:bob', 3))     # => 2
print(get_node('user:carol', 3))   # => 0

Avantages :

  • ✅ Distribution uniforme des données
  • ✅ Simple à implémenter
  • ✅ Pas de points chauds (en supposant une bonne fonction de hachage)

Désavantages :

  • ❌ Ne permet pas des requêtes de plage efficaces
  • ❌ Le rééquilibrage est coûteux lors de l'ajout/suppression de nœuds

2. Partitionnement Basé sur la Plage

Assigner des plages de clés à chaque nœud :

graph TB
    subgraph "Partitionnement Basé sur la Plage (3 nœuds)"
        R1["Nœud 1<br/>a-m"]
        R2["Nœud 2<br/>n-S"]
        R3["Nœud 3<br/>t-Z"]

        Key1["alice"] --> R1
        Key2["bob"] --> R1
        Key3["nancy"] --> R2
        Key4["steve"] --> R2
        Key5["tom"] --> R3
        Key6["zoe"] --> R3

        style R1 fill:#c8e6c9
        style R2 fill:#c8e6c9
        style R3 fill:#c8e6c9
    end

Exemple TypeScript :

interface Range {
    start: string;
    end: string;
    node: number;
}

const ranges: Range[] = [
    { start: 'a', end: 'm', node: 1 },
    { start: 'n', end: 's', node: 2 },
    { start: 't', end: 'z', node: 3 }
];

function getNodeByRange(key: string): number {
    for (const range of ranges) {
        if (key >= range.start && key <= range.end) {
            return range.node;
        }
    }
    throw new Error(`Aucune plage trouvée pour la clé : ${key}`);
}

// Exemples
console.log(getNodeByRange('alice'));  // => 1
console.log(getNodeByRange('nancy'));  // => 2
console.log(getNodeByRange('tom'));    // => 3

Exemple Python :

from typing import List, Tuple

ranges: List[Tuple[str, str, int]] = [
    ('a', 'm', 1),
    ('n', 's', 2),
    ('t', 'z', 3)
]

def get_node_by_range(key: str) -> int:
    """Déterminer quel nœud basé sur la plage de clés."""
    for start, end, node in ranges:
        if start <= key <= end:
            return node
    raise ValueError(f"Aucune plage trouvée pour la clé : {key}")

# Exemples
print(get_node_by_range('alice'))  # => 1
print(get_node_by_range('nancy'))  # => 2
print(get_node_by_range('tom'))    # => 3

Avantages :

  • ✅ Requêtes de plage efficaces
  • ✅ Peut optimiser pour les modèles d'accès aux données

Désavantages :

  • ❌ Distribution inégale (points chauds)
  • ❌ Complexe à équilibrer la charge

Le Problème du Rééquilibrage

Que se passe-t-il lorsque vous ajoutez ou supprimez des nœuds ?

stateDiagram-v2
    [*] --> Stable: 3 Nœuds
    Stable --> Rééquilibrage: Ajouter Nœud 4
    Rééquilibrage --> Stable: Déplacer 25% des données
    Stable --> Rééquilibrage: Supprimer Nœud 2
    Rééquilibrage --> Stable: Redistribuer les données

Problème du Hachage Modulo Simple

Avec hash(clé) % N, changer N de 3 à 4 signifie que la plupart des clés se déplacent vers différents nœuds :

Cléhash % 3hash % 4Déplacée ?
user:111
user:222
user:303
user:410
user:521
user:602

75% des clés se sont déplacées !

Hachage Cohérent (Avancé)

Une technique pour minimiser le déplacement de données lorsque les nœuds changent :

graph TB
    subgraph "Anneau de Hachage"
        Ring["Anneau Virtuel (0 - 2^32)"]

        N1["Nœud 1<br/>position : 100"]
        N2["Nœud 2<br/>position : 500"]
        N3["Nœud 3<br/>position : 900"]

        K1["Clé A<br/>hash : 150"]
        K2["Clé B<br/>hash : 600"]
        K3["Clé C<br/>hash : 950"]
    end

    Ring --> N1
    Ring --> N2
    Ring --> N3

    K1 -->|"sens horaire"| N2
    K2 -->|"sens horaire"| N3
    K3 -->|"sens horaire"| N1

    style Ring fill:#f9f,stroke:#333,stroke-width:2px

Idée Clé : Chaque clé est assignée au premier nœud dans le sens horaire à partir de sa position de hachage.

Lors de l'ajout/suppression d'un nœud, seules les clés dans la plage de ce nœud se déplacent.

Modèles de Requêtes et Partitionnement

Vos modèles de requêtes devraient influencer votre stratégie de partitionnement :

Modèles de Requêtes Courants

Type de RequêteMeilleur PartitionnementExemple
Recherches clé-valeurBasé sur le hachageObtenir un utilisateur par ID
Analyses de plageBasé sur la plageUtilisateurs inscrits la semaine dernière
Accès multi-clésHachage compositeCommandes par client
Requêtes géographiquesBasé sur la localisationRestaurants proches

Exemple : Partitionnement des Données Utilisateur

graph TB
    subgraph "Application : Réseau Social"
        Query1["Obtenir le Profil Utilisateur<br/>SELECT * FROM users WHERE id = ?"]
        Query2["Lister les Amis<br/>SELECT * FROM friends WHERE user_id = ?"]
        Query3["Publications de Timeline<br/>SELECT * FROM posts WHERE created_at > ?"]
    end

    subgraph "Décision de Partitionnement"
        Query1 -->|"hash(user_id)"| Hash[Hachage]
        Query2 -->|"hash(user_id)"| Hash
        Query3 -->|"range(created_at)"| Range[Plage]
    end

    subgraph "Résultat"
        Hash --> H["Données utilisateur & amis<br/>partitionnées par user_id"]
        Range --> R["Publications partitionnées<br/>par plage de dates"]
    end

Résumé des Compromis

StratégieDistributionRequêtes de PlageRééquilibrageComplexité
Basé sur le hachageUniformePauvreCoûteuxFaible
Basé sur la plagePotentiellement inégaleExcellentModéréMoyen
Hachage cohérentUniformePauvreMinimalÉlevé

Exemples Réels

SystèmeStratégie de PartitionnementNotes
Redis ClusterSlots de hachage (16384 slots)Hachage cohérent
CassandraSensible aux jetons (anneau de hachage)Partitionneur configurable
MongoDBPlages de clés de shardingBasé sur la plage sur la clé de sharding
DynamoDBHachage + plage (composite)Supporte les clés composites
PostgreSQLPas natifUtiliser des extensions comme Citus

Résumé

Points Clés à Retenir

  1. Le partitionnement divise les données sur plusieurs nœuds pour la mise à l'échelle
  2. Le hachage donne une distribution uniforme mais de mauvaises requêtes de plage
  3. La plage permet les analyses de plage mais peut créer des points chauds
  4. Le rééquilibrage est un défi clé lorsque les nœuds changent
  5. Les modèles de requêtes devraient dicter votre stratégie de partitionnement

Vérifiez Votre Compréhension

  • Pourquoi le partitionnement basé sur le hachage est-il meilleur pour une distribution uniforme ?
  • Quand choisiriez-vous le partitionnement par plage plutôt que par hachage ?
  • Qu'arrive-t-il au placement des données lorsque vous ajoutez un nouveau nœud avec le hachage modulo simple ?
  • Comment le hachage cohérent minimise-t-il le déplacement de données ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.

Et Ensuite

Maintenant que nous comprenons comment partitionner les données, explorons les compromis fondamentaux dans les systèmes de données distribués : Théorème CAP

Théorème CAP

Session 3, Partie 2 - 30 minutes

Objectifs d'Apprentissage

  • Comprendre le théorème CAP et ses trois composantes
  • Explorer les compromis entre Cohérence, Disponibilité et Tolérance aux Partitions
  • Identifier les systèmes réels et leurs choix CAP
  • Apprendre à appliquer la pensée CAP à la conception de systèmes

Qu'est-ce que le Théorème CAP ?

Le théorème CAP stipule qu'un magasin de données distribué ne peut fournir que deux des trois garanties suivantes :

graph TB
    subgraph "Triangle CAP - Choisissez-en Deux"
        C["Cohérence<br/>Chaque lecture reçoit<br/>l'écriture la plus récente"]
        A["Disponibilité<br/>Chaque requête reçoit<br/>une réponse"]
        P["Tolérance aux Partitions<br/>Le système opère<br/>malgré les défaillances réseau"]
    end

    C <--> A
    A <--> P
    P <--> C

    style C fill:#ffcdd2
    style A fill:#c8e6c9
    style P fill:#bbdefb

Les Trois Composantes

1. Cohérence (C)

Chaque lecture reçoit l'écriture la plus récente ou une erreur.

Tous les nœuds voient les mêmes données au même moment. Si vous écrivez une valeur et la lisez immédiatement, vous obtenez la valeur que vous venez d'écrire.

sequenceDiagram
    participant C as Client
    participant N1 as Nœud 1
    participant N2 as Nœud 2
    participant N3 as Nœud 3

    C->>N1: Écrire X = 10
    N1->>N2: Répliquer X
    N1->>N3: Répliquer X
    N2-->>N1: Ack
    N3-->>N1: Ack
    N1-->>C: Écriture confirmée

    Note over C,N3: Avant lecture...

    C->>N2: Lire X
    N2-->>C: X = 10 (plus récent)

    Note over C,N3: Tous les nœuds sont d'accord !

Exemple : Un système bancaire où votre solde doit être précis sur toutes les agences.

2. Disponibilité (A)

Chaque requête reçoit une réponse (non-erreur), sans garantie qu'elle contient l'écriture la plus récente.

Le système reste opérationnel même lorsque certains nœuds échouent. Vous pouvez toujours lire et écrire, même si les données peuvent être obsolètes.

sequenceDiagram
    participant C as Client
    participant N1 as Nœud 1 (en vie)
    participant N2 as Nœud 2 (mort)

    C->>N1: Écrire X = 10
    N1-->>C: Écriture confirmée

    Note over C,N2: N2 est en panne mais N1 répond...

    C->>N1: Lire X
    N1-->>C: X = 10

    Note over C,N2: Le système reste disponible !

Exemple : Un fil d'actualités sociales où montrer un contenu légèrement ancien est acceptable.

3. Tolérance aux Partitions (P)

Le système continue à opérer malgré un nombre arbitraire de messages étant abandonnés ou retardés par le réseau entre les nœuds.

Les partitions réseau sont inévitables dans les systèmes distribués. Le système doit les gérer avec grâce.

graph TB
    subgraph "Partition Réseau"
        N1["Nœud 1<br/>Ne peut atteindre N2, N3"]
        N2["Nœud 2<br/>Ne peut atteindre N1"]
        N3["Nœud 3<br/>Ne peut atteindre N1"]
    end

    N1 -.->|"🔴 Partition Réseau"| N2
    N1 -.->|"🔴 Partition Réseau"| N3
    N2 <--> N3
    N2 <--> N3

    style N1 fill:#ffcdd2
    style N2 fill:#c8e6c9
    style N3 fill:#c8e6c9

Aperçu Clé : Dans les systèmes distribués, P n'est pas optionnel — les partitions réseau ARRIVERONT.

Les Compromis

Puisque les partitions sont inévitables dans les systèmes distribués, le vrai choix est entre C et A pendant une partition :

stateDiagram-v2
    [*] --> Normal
    Normal --> Partitionné: Division Réseau
    Partitionné --> CP: Choisir Cohérence
    Partitionné --> AP: Choisir Disponibilité
    CP --> Normal: Partition guérie
    AP --> Normal: Partition guérie

    note right of CP
        Rejeter les écritures/lectures
        jusqu'à la synchronisation des données
    end note

    note right of AP
        Accepter les écritures/lectures
        les données peuvent être obsolètes
    end note

CP : Cohérence + Tolérance aux Partitions

Sacrifier la Disponibilité

Pendant une partition, le système retourne des erreurs ou bloque jusqu'à ce que la cohérence puisse être garantie.

sequenceDiagram
    participant C as Client
    participant N1 as Nœud 1 (primaire)
    participant N2 as Nœud 2 (isolé)

    Note over N1,N2: 🔴 Partition Réseau

    C->>N1: Écrire X = 10
    N1-->>C: ❌ Erreur : Impossible de répliquer

    C->>N2: Lire X
    N2-->>C: ❌ Erreur : Données indisponibles

    Note over C,N2: Le système bloque plutôt<br/>que de retourner des données obsolètes

Exemples :

  • MongoDB (avec souci d'écriture majoritaire)
  • HBase
  • Redis (avec configuration appropriée)
  • SGBD traditionnels avec réplication synchrone

Utiliser lorsque : La précision des données est critique (systèmes financiers, inventaire)

AP : Disponibilité + Tolérance aux Partitions

Sacrifier la Cohérence

Pendant une partition, le système accepte les lectures et écritures, pouvant retourner des données obsolètes.

sequenceDiagram
    participant C as Client
    participant N1 as Nœud 1 (accepte écritures)
    participant N2 as Nœud 2 (a anciennes données)

    Note over N1,N2: 🔴 Partition Réseau

    C->>N1: Écrire X = 10
    N1-->>C: ✅ OK (écrit sur N1 seulement)

    C->>N2: Lire X
    N2-->>C: ✅ X = 5 (obsolète !)

    Note over C,N2: Le système accepte les requêtes<br/>mais les données sont incohérentes

Exemples :

  • Cassandra
  • DynamoDB
  • CouchDB
  • Riak

Utiliser lorsque : Toujours répondre est plus important que la cohérence immédiate (médias sociaux, mise en cache, analyses)

CA : Cohérence + Disponibilité

Possible uniquement dans les systèmes à nœud unique

Sans partitions réseau (nœud unique ou réseau parfaitement fiable), vous pouvez avoir à la fois C et A.

graph TB
    Single["Base de Données à Nœud Unique"]
    Client["Client"]

    Client --> Single
    Single <--> Client

    Note1[Pas de réseau = Pas de partitions]
    Note --> Single

    style Single fill:#fff9c4

Exemples :

  • PostgreSQL à nœud unique
  • MongoDB à nœud unique
  • SGBD traditionnels sur un serveur

Réalité : Dans les systèmes distribués, CA n'est pas achievable car les réseaux ne sont pas parfaitement fiables.

Exemples CAP Réels

SystèmeChoix CAPNotes
Google SpannerCPCohérence externe, toujours cohérent
Amazon DynamoDBAPCohérence configurable
CassandraAPToujours inscriptible, cohérence ajustable
MongoDBCP (par défaut)Configurable en AP
Redis ClusterAPRéplication asynchrone
PostgreSQLCAMode nœud unique
CockroachDBCPSérialisabilité, gère les partitions
CouchbaseAPRéplication Inter-Centres de Données

Modèles de Cohérence

La "Cohérence" du théorème CAP est en fait la linéarisabilité (cohérence forte). Il existe plusieurs modèles de cohérence :

graph TB
    subgraph "Spectre de Cohérence"
        Strong["Cohérence Forte<br/>Linéarisabilité"]
        Weak["Cohérence Faible<br/>Cohérence Finale"]

        Strong --> S1["Cohérence<br/>Séquentielle"]
        S1 --> S2["Cohérence<br/>Causale"]
        S2 --> S3["Cohérence de<br/>Session"]
        S3 --> S4["Lire Vos<br/>Écritures"]
        S4 --> Weak
    end

Modèles de Cohérence Forte

ModèleDescriptionExemple
LinéarisableLecture la plus récente garantieTransferts bancaires
SéquentielleLes opérations apparaissent dans un certain ordreContrôle de version
CausaleOpérations causalement liées ordonnéesApplications de chat

Modèles de Cohérence Faible

ModèleDescriptionExemple
Lire Vos ÉcrituresL'utilisateur voit ses propres écrituresProfil de médias sociaux
Cohérence de SessionCohérence dans une sessionPanier d'achat
Cohérence FinaleLe système converge au fil du tempsDNS, CDN

Exemple Pratique : Panier d'Achat

Voyons comment différents choix CAP affectent un système de panier d'achat :

Approche CP (Bloquer sur Partition)

sequenceDiagram
    participant U as Utilisateur
    participant S as Service

    Note over U,S: 🔴 Partition réseau détectée

    U->>S: Ajouter article au panier
    S-->>U: ❌ Erreur : Service indisponible

    Note over U,S: Utilisateur frustré,<br/>mais panier est toujours précis

Compromis : Ventes perdues, panier précis

Approche AP (Accepter Écritures)

sequenceDiagram
    participant U as Utilisateur
    participant S as Service

    Note over U,S: 🔴 Partition réseau détectée

    U->>S: Ajouter article au panier
    S-->>U: ✅ OK (écrit localement)

    Note over U,S: Utilisateur satisfait,<br/>mais panier peut être en conflit

Compromis : Utilisateurs satisfaits, conflits de fusion possibles ultérieurement

La Simplification "2 sur 3"

Le théorème CAP est souvent mal compris. La réalité est plus nuancée :

graph TB
    subgraph "Réalité CAP"
        CAP["Théorème CAP"]

        CAP --> Malcompréhension["Vous devez choisir<br/>exactement 2"]
        CAP --> Réalité["Vous pouvez avoir les 3<br/>en opération normale"]
        CAP --> Vérité["Pendant partition,<br/>choisir C ou A"]
    end

Aperçus Clés :

  1. P est obligatoire dans les systèmes distribués
  2. Pendant l'opération normale, vous pouvez avoir C + A + P
  3. Pendant une partition, vous choisissez entre C et A
  4. Plusieurs systèmes sont configurables (par exemple, DynamoDB)

Directives de Conception

Choisir CP Lorsque :

  • ✅ Transactions financières
  • ✅ Gestion d'inventaire
  • ✅ Authentification/autorisation
  • ✅ Tout système où les données obsolètes sont inacceptables

Choisir AP Lorsque :

  • ✅ Fils d'actualités sociaux
  • ✅ Recommandations de produits
  • ✅ Analyses et journalisation
  • ✅ Tout système où la disponibilité est critique

Techniques pour Équilibrer C et A :

TechniqueDescriptionExemple
Lectures/écritures de quorumNécessite une reconnaissance majoritaireDynamoDB
Cohérence ajustableLaisser le client choisir par opérationCassandra
Dégradation gracieuseChanger de modes pendant partitionPlusieurs systèmes
Résolution de conflitsFusionner les données divergentes ultérieurementCRDTs

Résumé

Points Clés à Retenir

  1. Théorème CAP : Vous ne pouvez pas avoir les trois dans une partition
  2. La tolérance aux partitions est obligatoire dans les systèmes distribués
  3. Le vrai choix : Cohérence vs Disponibilité pendant partition
  4. Plusieurs systèmes offrent des niveaux de cohérence ajustables
  5. Votre cas d'utilisation détermine le bon compromis

Vérifiez Votre Compréhension

  • Pourquoi la tolérance aux partitions n'est-elle pas optionnelle dans les systèmes distribués ?
  • Donnez un exemple où vous choisiriez CP plutôt que AP
  • Qu'arrive-t-il à un système AP pendant une partition réseau ?
  • Comment les lectures/écritures de quorum peuvent-elles aider à équilibrer C et A ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.

Et Ensuite

Maintenant que nous comprenons les compromis CAP, construisons un simple magasin clé-valeur : Bases du Magasin

Bases du Système de Magasin

Session 3, Partie 3 - 35 minutes (démo de codage + pratique)

Objectifs d'Apprentissage

  • Comprendre le modèle de données clé-valeur
  • Construire un magasin clé-valeur à nœud unique en TypeScript
  • Construire le même magasin en Python
  • Déployer et tester le magasin en utilisant Docker Compose
  • Effectuer des opérations de lecture/écriture de base via HTTP

Qu'est-ce qu'un Magasin Clé-Valeur ?

Un magasin clé-valeur est le type le plus simple de base de données :

graph LR
    subgraph "Magasin Clé-Valeur"
        KV[("Magasin de Données")]

        K1["nom"] --> V1[""Alice""]
        K2["âge"] --> V2["30"]
        K3["ville"] --> V3[""NYC""]
        K4["actif"] --> V4["true"]

        K1 --> KV
        K2 --> KV
        K3 --> KV
        K4 --> KV
    end

Caractéristiques Clés :

  • Modèle de données simple : clé → valeur
  • Recherches rapides par clé
  • Pas de requêtes complexes
  • Sans schéma

Opérations de Base

OpérationDescriptionExemple
SETStocker une valeur pour une cléSET user:1 Alice
GETRécupérer une valeur par cléGET user:1 → "Alice"
DELETESupprimer une cléDELETE user:1
stateDiagram-v2
    [*] --> NonExistant
    NonExistant --> Existant: SET clé
    Existant --> Existant: SET clé (mise à jour)
    Existant --> NonExistant: DELETE clé
    Existant --> Existant: GET clé (lecture)
    NonExistant --> [*]: GET clé (null)

Implémentation

Nous allons construire un simple magasin clé-valeur basé sur HTTP avec des points de terminaison API REST.

Conception de l'API

GET    /key/{clé}      - Obtenir la valeur par clé
PUT    /key/{clé}      - Définir la valeur pour la clé
DELETE /key/{clé}      - Supprimer la clé
GET    /keys           - Lister toutes les clés

Implémentation TypeScript

Structure du Projet

store-basics-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
└── src/
    └── store.ts       # Implémentation complète du magasin

Code TypeScript Complet

store-basics-ts/src/store.ts

import http from 'http';

/**
 * Magasin clé-valeur simple en mémoire
 */
class KeyValueStore {
  private data: Map<string, any> = new Map();

  /**
   * Définir une paire clé-valeur
   */
  set(key: string, value: any): void {
    this.data.set(key, value);
    console.log(`[Store] SET ${key} = ${JSON.stringify(value)}`);
  }

  /**
   * Obtenir une valeur par clé
   */
  get(key: string): any {
    const value = this.data.get(key);
    console.log(`[Store] GET ${key} => ${value !== undefined ? JSON.stringify(value) : 'null'}`);
    return value;
  }

  /**
   * Supprimer une clé
   */
  delete(key: string): boolean {
    const existed = this.data.delete(key);
    console.log(`[Store] DELETE ${key} => ${existed ? 'succès' : 'non trouvé'}`);
    return existed;
  }

  /**
   * Obtenir toutes les clés
   */
  keys(): string[] {
    return Array.from(this.data.keys());
  }

  /**
   * Obtenir les statistiques du magasin
   */
  stats() {
    return {
      totalKeys: this.data.size,
      keys: this.keys()
    };
  }
}

// Créer l'instance du magasin
const store = new KeyValueStore();

/**
 * Serveur HTTP avec API clé-valeur
 */
const server = http.createServer((req, res) => {
  // Activer CORS
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (req.method === 'OPTIONS') {
    res.writeHead(200);
    res.end();
    return;
  }

  // Analyser l'URL
  const url = new URL(req.url || '', `http://${req.headers.host}`);

  // Route : GET /keys - Lister toutes les clés
  if (req.method === 'GET' && url.pathname === '/keys') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(store.stats()));
    return;
  }

  // Route : GET /key/{clé} - Obtenir la valeur
  if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5); // Retirer '/key/'
    const value = store.get(key);

    if (value !== undefined) {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ key, value }));
    } else {
      res.writeHead(404, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Key not found', key }));
    }
    return;
  }

  // Route : PUT /key/{clé} - Définir la valeur
  if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5); // Retirer '/key/'

    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const value = JSON.parse(body);
        store.set(key, value);

        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ success: true, key, value }));
      } catch (error) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });
    return;
  }

  // Route : DELETE /key/{clé} - Supprimer la clé
  if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5); // Retirer '/key/'
    const existed = store.delete(key);

    if (existed) {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ success: true, key }));
    } else {
      res.writeHead(404, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Key not found', key }));
    }
    return;
  }

  // 404 - Non trouvé
  res.writeHead(404, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'Not found' }));
});

const PORT = process.env.PORT || 4000;
server.listen(PORT, () => {
  console.log(`Key-Value Store listening on port ${PORT}`);
  console.log(`\nAvailable endpoints:`);
  console.log(`  GET    /key/{key}    - Get value by key`);
  console.log(`  PUT    /key/{key}    - Set value for key`);
  console.log(`  DELETE /key/{key}    - Delete key`);
  console.log(`  GET    /keys         - List all keys`);
});

store-basics-ts/package.json

{
  "name": "store-basics-ts",
  "version": "1.0.0",
  "description": "Simple key-value store in TypeScript",
  "main": "dist/store.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/store.js",
    "dev": "ts-node src/store.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.9.0"
  }
}

store-basics-ts/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

store-basics-ts/Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 4000

CMD ["npm", "start"]

Implémentation Python

Structure du Projet

store-basics-py/
├── requirements.txt
├── Dockerfile
└── src/
    └── store.py       # Implémentation complète du magasin

Code Python Complet

store-basics-py/src/store.py

from http.server import HTTPServer, BaseHTTPRequestHandler
import json
from typing import Any, Dict
from urllib.parse import urlparse

class KeyValueStore:
    """Magasin clé-valeur simple en mémoire."""

    def __init__(self):
        self.data: Dict[str, Any] = {}

    def set(self, key: str, value: Any) -> None:
        """Stocker une paire clé-valeur."""
        self.data[key] = value
        print(f"[Store] SET {key} = {json.dumps(value)}")

    def get(self, key: str) -> Any:
        """Obtenir la valeur par clé."""
        value = self.data.get(key)
        print(f"[Store] GET {key} => {json.dumps(value) if value is not None else 'null'}")
        return value

    def delete(self, key: str) -> bool:
        """Supprimer une clé."""
        existed = key in self.data
        if existed:
            del self.data[key]
        print(f"[Store] DELETE {key} => {'success' if existed else 'not found'}")
        return existed

    def keys(self) -> list:
        """Obtenir toutes les clés."""
        return list(self.data.keys())

    def stats(self) -> dict:
        """Obtenir les statistiques du magasin."""
        return {
            'totalKeys': len(self.data),
            'keys': self.keys()
        }


# Créer l'instance du magasin
store = KeyValueStore()


class StoreHandler(BaseHTTPRequestHandler):
    """Gestionnaire de requêtes HTTP pour le magasin clé-valeur."""

    def send_json_response(self, status: int, data: dict):
        """Envoyer une réponse JSON."""
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def do_OPTIONS(self):
        """Gérer les requêtes préalables CORS."""
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def do_GET(self):
        """Gérer les requêtes GET."""
        parsed = urlparse(self.path)

        # GET /keys - Lister toutes les clés
        if parsed.path == '/keys':
            self.send_json_response(200, store.stats())
            return

        # GET /key/{clé} - Obtenir la valeur
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]  # Retirer '/key/'
            value = store.get(key)

            if value is not None:
                self.send_json_response(200, {'key': key, 'value': value})
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        # 404
        self.send_json_response(404, {'error': 'Not found'})

    def do_PUT(self):
        """Gérer les requêtes PUT (définir valeur)."""
        parsed = urlparse(self.path)

        # PUT /key/{clé} - Définir la valeur
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]  # Retirer '/key/'

            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')

            try:
                value = json.loads(body)
                store.set(key, value)
                self.send_json_response(200, {'success': True, 'key': key, 'value': value})
            except json.JSONDecodeError:
                self.send_json_response(400, {'error': 'Invalid JSON'})
            return

        # 404
        self.send_json_response(404, {'error': 'Not found'})

    def do_DELETE(self):
        """Gérer les requêtes DELETE."""
        parsed = urlparse(self.path)

        # DELETE /key/{clé} - Supprimer la clé
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]  # Retirer '/key/'
            existed = store.delete(key)

            if existed:
                self.send_json_response(200, {'success': True, 'key': key})
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        # 404
        self.send_json_response(404, {'error': 'Not found'})

    def log_message(self, format, *args):
        """Supprimer la journalisation par défaut."""
        pass


def run_server(port: int = 4000):
    """Démarrer le serveur HTTP."""
    server_address = ('', port)
    httpd = HTTPServer(server_address, StoreHandler)
    print(f"Key-Value Store listening on port {port}")
    print(f"\nAvailable endpoints:")
    print(f"  GET    /key/{{key}}    - Get value by key")
    print(f"  PUT    /key/{{key}}    - Set value for key")
    print(f"  DELETE /key/{{key}}    - Delete key")
    print(f"  GET    /keys         - List all keys")
    httpd.serve_forever()


if __name__ == '__main__':
    import os
    port = int(os.environ.get('PORT', 4000))
    run_server(port)

store-basics-py/requirements.txt

# Aucune dépendance externe requise - utilise uniquement la bibliothèque standard

store-basics-py/Dockerfile

FROM python:3.11-alpine

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 4000

CMD ["python", "src/store.py"]

Configuration Docker Compose

Version TypeScript

examples/02-store/ts/docker-compose.yml

version: '3.8'

services:
  store:
    build: .
    ports:
      - "4000:4000"
    environment:
      - PORT=4000
    volumes:
      - ./src:/app/src

Version Python

examples/02-store/py/docker-compose.yml

version: '3.8'

services:
  store:
    build: .
    ports:
      - "4000:4000"
    environment:
      - PORT=4000
    volumes:
      - ./src:/app/src

Exécution de l'Exemple

Étape 1 : Démarrer le Magasin

TypeScript :

cd examples/02-store/ts
docker-compose up --build

Python :

cd examples/02-store/py
docker-compose up --build

Vous devriez voir :

store    | Key-Value Store listening on port 4000
store    |
store    | Available endpoints:
store    |   GET    /key/{key}    - Get value by key
store    |   PUT    /key/{key}    - Set value for key
store    |   DELETE /key/{key}    - Delete key
store    |   GET    /keys         - List all keys

Étape 2 : Stocker Quelques Valeurs

# Stocker une chaîne
curl -X PUT http://localhost:4000/key/name \
  -H "Content-Type: application/json" \
  -d '"Alice"'

# Stocker un nombre
curl -X PUT http://localhost:4000/key/age \
  -H "Content-Type: application/json" \
  -d '30'

# Stocker un objet
curl -X PUT http://localhost:4000/key/user:1 \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "age": 30, "city": "NYC"}'

# Stocker une liste
curl -X PUT http://localhost:4000/key/tags \
  -H "Content-Type: application/json" \
  -d '["distributed", "systems", "course"]'

Étape 3 : Récupérer les Valeurs

# Obtenir une chaîne
curl http://localhost:4000/key/name
# Response: {"key":"name","value":"Alice"}

# Obtenir un nombre
curl http://localhost:4000/key/age
# Response: {"key":"age","value":30}

# Obtenir un objet
curl http://localhost:4000/key/user:1
# Response: {"key":"user:1","value":{"name":"Alice","age":30,"city":"NYC"}}

# Obtenir une liste
curl http://localhost:4000/key/tags
# Response: {"key":"tags","value":["distributed","systems","course"]}

# Essayer d'obtenir une clé inexistante
curl http://localhost:4000/key/nonexistent
# Response: {"error":"Key not found","key":"nonexistent"}

Étape 4 : Lister Toutes les Clés

curl http://localhost:4000/keys
# Response: {"totalKeys":4,"keys":["name","age","user:1","tags"]}

Étape 5 : Supprimer une Clé

# Supprimer une clé
curl -X DELETE http://localhost:4000/key/age
# Response: {"success":true,"key":"age"}

# Vérifier qu'elle a disparu
curl http://localhost:4000/key/age
# Response: {"error":"Key not found","key":"age"}

# Vérifier les clés restantes
curl http://localhost:4000/keys
# Response: {"totalKeys":3,"keys":["name","user:1","tags"]}

Architecture du Système

graph TB
    subgraph "Magasin Clé-Valeur à Nœud Unique"
        Client["Applications Clientes"]

        API["API HTTP"]

        Store[("Données en<br/>Mémoire")]

        Client -->|"GET/PUT/DELETE"| API
        API --> Store
    end

    style Store fill:#f9f,stroke:#333,stroke-width:3px

Exercices

Exercice 1 : Ajouter le Support TTL (Time-To-Live)

Modifier le magasin pour expirer automatiquement les clés après un temps spécifié :

  1. Ajouter un paramètre ttl optionnel à l'opération SET
  2. Suivre quand chaque clé devrait expirer
  3. Retourner null pour les clés expirées
  4. Implémenter un mécanisme de nettoyage

Indice : Stocker les métadonnées alongside les valeurs, ou utiliser une carte d'expiration séparée.

Exercice 2 : Ajouter des Motifs de Clés

Ajouter le support des caractères génériques pour les recherches de clés :

  1. Implémenter GET /keys?pattern=user:* pour lister les clés correspondantes
  2. Supporter les correspondances avec caractère générique * simple
  3. Tester avec des motifs comme user:*, *:admin, etc.

Exercice 3 : Ajouter la Persistance des Données

Actuellement les données sont perdues lorsque le serveur redémarre. Ajouter la persistance :

  1. Sauvegarder les données dans un fichier JSON à chaque écriture
  2. Charger les données depuis le fichier au démarrage
  3. Gérer les écritures simultanées en toute sécurité

Résumé

Points Clés à Retenir

  1. Les magasins clé-valeur sont des systèmes de stockage de données simples mais puissants
  2. Opérations de base : SET, GET, DELETE
  3. L'API HTTP fournit une interface simple pour l'accès à distance
  4. Les magasins à nœud unique sont CA (Cohérent + Disponible) selon la perspective CAP
  5. Prochaines étapes : Ajouter la réplication pour la tolérance aux pannes (Session 4)

Vérifiez Votre Compréhension

  • Quelles sont les quatre opérations de base que nous avons implémentées ?
  • Comment notre magasin gère-t-il les requêtes pour les clés inexistantes ?
  • Qu'arrive-t-il aux données lorsque le conteneur Docker s'arrête ?
  • Pourquoi ce magasin à nœud unique est-il "CA" selon les termes CAP ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.

Et Ensuite

Notre simple magasin fonctionne, mais qu'arrive-t-il lorsqu'un nœud échoue ? Ajoutons la réplication : Réplication (Session 4)

Replication et Election de Leader

Session 4 - Session complète

Objectifs d'Apprentissage

  • Comprendre pourquoi nous répliquons les données
  • Apprendre la réplication à leader unique vs multi-leader
  • Implémenter la réplication basée sur un leader
  • Construire un mécanisme simple d'élection de leader
  • Déployer un magasin répliqué à 3 nœuds

Pourquoi Répliquer les Données ?

Dans notre magasin à nœud unique de la Session 3, que se passe-t-il lorsque le nœud tombe en panne ?

Réponse : Toutes les données sont perdues et le système devient indisponible.

graph LR
    subgraph "Nœud Unique - Pas de Tolérance aux Pannes"
        C[Clients] --> N[Node 1]
        N1[Node 1<br/>❌ FAILED]
        style N1 fill:#f66,stroke:#333,stroke-width:3px
    end

La réplication résout ce problème en gardant des copies des données sur plusieurs nœuds :

graph TB
    subgraph "Magasin Répliqué - Tolérant aux Pannes"
        C[Clients]

        L[Leader<br/>Node 1]

        F1[Suiveur<br/>Node 2]
        F2[Suiveur<br/>Node 3]

        C --> L
        L -->|"réplique"| F1
        L -->|"réplique"| F2
    end

    style L fill:#6f6,stroke:#333,stroke-width:3px

Avantages de la Réplication :

  • Tolérance aux pannes : Si un nœud tombe en panne, les autres ont les données
  • Mise à l'échelle des lectures : Les clients peuvent lire depuis n'importe quel réplica
  • Faible latence : Placer les répliques plus près des utilisateurs
  • Haute disponibilité : Le système continue pendant les pannes de nœuds

Stratégies de Réplication

Réplication à Leader Unique

Également appelée : primaire-réplique, maître-esclave, actif-passif

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F1 as Suiveur 1
    participant F2 as Suiveur 2

    Note over C,F2: Opération d'Écriture
    C->>L: PUT /key/name "Alice"
    L->>L: Écrire dans le stockage local
    L->>F1: Répliquer : SET name = "Alice"
    L->>F2: Répliquer : SET name = "Alice"
    F1->>L: ACK
    F2->>L: ACK
    L->>C: Réponse : Success

    Note over C,F2: Opération de Lecture
    C->>L: GET /key/name
    L->>C: Réponse : "Alice"

    Note over C,F2: Ou lire depuis le suiveur
    C->>F1: GET /key/name
    F1->>C: Réponse : "Alice"

Caractéristiques :

  • Le Leader gère toutes les écritures
  • Les Suiveurs se répliquent depuis le leader
  • Les Lectures peuvent aller vers le leader ou les suiveurs
  • Modèle de cohérence simple

Réplication Multi-Leader

Également appelée : multi-maître, actif-actif

graph TB
    subgraph "Réplication Multi-Leader"
        C1[Client 1]
        C2[Client 2]

        L1[Leader 1<br/>Datacenter A]
        L2[Leader 2<br/>Datacenter B]

        F1[Suiveur 1]
        F2[Suiveur 2]

        C1 --> L1
        C2 --> L2

        L1 <-->|"résoudre les conflits"| L2

        L1 --> F1
        L2 --> F2
    end

    style L1 fill:#6f6,stroke:#333,stroke-width:3px
    style L2 fill:#6f6,stroke:#333,stroke-width:3px

Caractéristiques :

  • Plusieurs nœuds acceptent les écritures
  • Résolution de conflits plus complexe
  • Mieux pour les configurations géo-distribuées
  • Nous ne l'implémenterons pas (sujet avancé)

Réplication Synchrone vs Asynchrone

sequenceDiagram
    participant C as Client
    participant L as Leader

    par Réplication Synchrone
        L->>F: Répliquer l'écriture
        F->>L: ACK (doit attendre)
        L->>C: Success (après confirmation des répliques)
    and Réplication Asynchrone
        L->>C: Success (immédiatement)
        L--xF: Répliquer en arrière-plan
    end

    participant F as Suiveur
StratégieAvantagesInconvénients
SynchroneCohérence forte, aucune perte de donnéesÉcritures plus lentes, bloquant
AsynchroneÉcritures rapides, non-bloquantPerte de données en cas de panne du leader, lectures périmées

Pour ce cours, nous utiliserons la réplication asynchrone pour simplifier.

Élection de Leader

Lorsque le leader tombe en panne, les suiveurs doivent élire un nouveau leader :

stateDiagram-v2
    [*] --> Suiveur: Le nœud démarre
    Suiveur --> Candidat: Pas de heartbeat du leader
    Candidat --> Leader: Gagne l'élection (majorité des votes)
    Candidat --> Suiveur: Perd l'élection
    Leader --> Suiveur: Détecte un terme/nœud supérieur
    Suiveur --> [*]: Le nœud s'arrête

L'Algorithme du Bully

Un algorithme simple d'élection de leader :

  1. Détecter la panne du leader : Pas de heartbeat pendant la période de timeout
  2. Démarrer l'élection : Le nœud avec l'ID le plus élevé devient candidat leader
  3. Voter : Les nœuds avec des numéros inférieurs votent pour le candidat
  4. Devenir leader : Le candidat devient leader si la majorité est d'accord
sequenceDiagram
    participant N1 as Nœud 1<br/>(Leader)
    participant N2 as Nœud 2
    participant N3 as Nœud 3

    Note over N1,N3: Fonctionnement Normal
    N1->>N2: Heartbeat
    N1->>N3: Heartbeat

    Note over N1,N3: Panne du Leader
    N1--xN2: Heartbeat timeout !
    N1--xN3: Heartbeat timeout !

    Note over N2,N3: Début de l'Élection
    N2->>N3: Demande de vote (ID=2)
    N3->>N2: Voter pour N2 (2 > 3 ? Non, attendre)

    Note over N2,N3: En fait, N3 a un ID plus élevé
    N3->>N2: Demande de vote (ID=3)
    N2->>N3: Voter pour N3 (3 > 2, oui !)

    Note over N2,N3: N3 Devient Leader
    N3->>N2: Je suis le leader
    N3->>N2: Heartbeat

Pour simplifier, nous utiliserons une approche plus simple :

  • Le nœud avec l'ID le plus bas devient leader
  • Si le leader tombe en panne, le prochain plus bas devient leader
  • Pas de vote, juste une sélection basée sur l'ordre

Implémentation

Implémentation TypeScript

Structure du Projet :

replicated-store-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── src/
    └── node.ts       # Nœud répliqué avec élection de leader

replicated-store-ts/src/node.ts

import http from 'http';

/**
 * Configuration du nœud
 */
const config = {
  nodeId: process.env.NODE_ID || 'node-1',
  port: parseInt(process.env.PORT || '4000'),
  peers: (process.env.PEERS || '').split(',').filter(Boolean),
  heartbeatInterval: 2000,  // ms
  electionTimeout: 6000,     // ms
};

type NodeRole = 'leader' | 'follower' | 'candidate';

/**
 * Nœud de Magasin Répliqué
 */
class StoreNode {
  public nodeId: string;
  public role: NodeRole;
  public term: number;
  public data: Map<string, any>;
  public peers: string[];

  private leaderId: string | null;
  private lastHeartbeat: number;
  private heartbeatTimer?: NodeJS.Timeout;
  private electionTimer?: NodeJS.Timeout;

  constructor(nodeId: string, peers: string[]) {
    this.nodeId = nodeId;
    this.role = 'follower';
    this.term = 0;
    this.data = new Map();
    this.peers = peers;
    this.leaderId = null;
    this.lastHeartbeat = Date.now();

    this.startElectionTimer();
    this.startHeartbeat();
  }

  /**
   * Démarrer le timer de timeout d'élection
   */
  private startElectionTimer() {
    this.electionTimer = setTimeout(() => {
      const timeSinceHeartbeat = Date.now() - this.lastHeartbeat;
      if (timeSinceHeartbeat > config.electionTimeout && this.role !== 'leader') {
        console.log(`[${this.nodeId}] Election timeout ! Démarrage de l'élection...`);
        this.startElection();
      }
      this.startElectionTimer();
    }, config.electionTimeout);
  }

  /**
   * Démarrer l'élection de leader (simplifié : l'ID le plus bas gagne)
   */
  private startElection() {
    this.term++;
    this.role = 'candidate';

    // Stratégie simple : le nœud avec l'ID le plus bas devient leader
    const allNodes = [this.nodeId, ...this.peers].sort();
    const lowestNode = allNodes[0];

    if (this.nodeId === lowestNode) {
      this.becomeLeader();
    } else {
      this.role = 'follower';
      this.leaderId = lowestNode;
      console.log(`[${this.nodeId}] En attente de ${lowestNode} pour devenir leader`);
    }
  }

  /**
   * Devenir le leader
   */
  private becomeLeader() {
    this.role = 'leader';
    this.leaderId = this.nodeId;
    console.log(`[${this.nodeId}] 👑 Devenu LEADER pour le terme ${this.term}`);

    // Répliquer immédiatement aux suiveurs
    this.replicateToFollowers();
  }

  /**
   * Démarrer le heartbeat vers les suiveurs
   */
  private startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.role === 'leader') {
        this.sendHeartbeat();
      }
    }, config.heartbeatInterval);
  }

  /**
   * Envoyer le heartbeat à tous les suiveurs
   */
  private sendHeartbeat() {
    const heartbeat = {
      type: 'heartbeat',
      leaderId: this.nodeId,
      term: this.term,
      timestamp: Date.now(),
    };

    this.peers.forEach(peerUrl => {
      this.sendToPeer(peerUrl, '/internal/heartbeat', heartbeat)
        .catch(err => console.log(`[${this.nodeId}] Échec de l'envoi du heartbeat à ${peerUrl}:`, err.message));
    });
  }

  /**
   * Répliquer les données à tous les suiveurs
   */
  private replicateToFollowers() {
    // Convertir Map en objet pour la réplication
    const dataObj = Object.fromEntries(this.data);

    this.peers.forEach(peerUrl => {
      this.sendToPeer(peerUrl, '/internal/replicate', {
        type: 'replicate',
        leaderId: this.nodeId,
        term: this.term,
        data: dataObj,
      }).catch(err => console.log(`[${this.nodeId}] Réplication échouée vers ${peerUrl}:`, err.message));
    });
  }

  /**
   * Gérer le heartbeat du leader
   */
  handleHeartbeat(heartbeat: any) {
    if (heartbeat.term >= this.term) {
      this.term = heartbeat.term;
      this.lastHeartbeat = Date.now();
      this.leaderId = heartbeat.leaderId;
      this.role = 'follower';

      if (this.role !== 'follower') {
        console.log(`[${this.nodeId}] Rétrogradation en suiveur, terme ${this.term}`);
      }
    }
  }

  /**
   * Gérer la réplication du leader
   */
  handleReplication(message: any) {
    if (message.term >= this.term) {
      this.term = message.term;
      this.leaderId = message.leaderId;
      this.role = 'follower';
      this.lastHeartbeat = Date.now();

      // Fusionner les données répliquées
      Object.entries(message.data).forEach(([key, value]) => {
        this.data.set(key, value);
      });

      console.log(`[${this.nodeId}] ${Object.keys(message.data).length} clés répliquées depuis le leader`);
    }
  }

  /**
   * Envoyer des données à un nœud pair
   */
  private async sendToPeer(peerUrl: string, path: string, data: any): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = new URL(path, peerUrl);
      const options = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      };

      const req = http.request(url, options, (res) => {
        if (res.statusCode === 200) {
          resolve();
        } else {
          reject(new Error(`Status ${res.statusCode}`));
        }
      });

      req.on('error', reject);
      req.write(JSON.stringify(data));
      req.end();
    });
  }

  /**
   * Définir une paire clé-valeur (seulement sur le leader)
   */
  set(key: string, value: any): boolean {
    if (this.role !== 'leader') {
      return false;
    }

    this.data.set(key, value);
    console.log(`[${this.nodeId}] SET ${key} = ${JSON.stringify(value)}`);

    // Répliquer aux suiveurs
    this.replicateToFollowers();

    return true;
  }

  /**
   * Obtenir une valeur par clé
   */
  get(key: string): any {
    const value = this.data.get(key);
    console.log(`[${this.nodeId}] GET ${key} => ${value !== undefined ? JSON.stringify(value) : 'null'}`);
    return value;
  }

  /**
   * Supprimer une clé
   */
  delete(key: string): boolean {
    if (this.role !== 'leader') {
      return false;
    }

    const existed = this.data.delete(key);
    console.log(`[${this.nodeId}] DELETE ${key} => ${existed ? 'success' : 'not found'}`);

    // Répliquer aux suiveurs
    this.replicateToFollowers();

    return existed;
  }

  /**
   * Obtenir le statut du nœud
   */
  getStatus() {
    return {
      nodeId: this.nodeId,
      role: this.role,
      term: this.term,
      leaderId: this.leaderId,
      totalKeys: this.data.size,
      keys: Array.from(this.data.keys()),
    };
  }
}

// Créer le nœud
const node = new StoreNode(config.nodeId, config.peers);

/**
 * Serveur HTTP
 */
const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (req.method === 'OPTIONS') {
    res.writeHead(200);
    res.end();
    return;
  }

  const url = new URL(req.url || '', `http://${req.headers.host}`);

  // Route : POST /internal/heartbeat - Heartbeat du leader
  if (req.method === 'POST' && url.pathname === '/internal/heartbeat') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const heartbeat = JSON.parse(body);
        node.handleHeartbeat(heartbeat);
        res.writeHead(200);
        res.end(JSON.stringify({ success: true }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid request' }));
      }
    });
    return;
  }

  // Route : POST /internal/replicate - Réplication du leader
  if (req.method === 'POST' && url.pathname === '/internal/replicate') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const message = JSON.parse(body);
        node.handleReplication(message);
        res.writeHead(200);
        res.end(JSON.stringify({ success: true }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid request' }));
      }
    });
    return;
  }

  // Route : GET /status - Statut du nœud
  if (req.method === 'GET' && url.pathname === '/status') {
    res.writeHead(200);
    res.end(JSON.stringify(node.getStatus()));
    return;
  }

  // Route : GET /key/{key} - Obtenir une valeur
  if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);
    const value = node.get(key);

    if (value !== undefined) {
      res.writeHead(200);
      res.end(JSON.stringify({ key, value, nodeRole: node.role }));
    } else {
      res.writeHead(404);
      res.end(JSON.stringify({ error: 'Key not found', key }));
    }
    return;
  }

  // Route : PUT /key/{key} - Définir une valeur (leader uniquement)
  if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);

    if (node.role !== 'leader') {
      res.writeHead(503);
      res.end(JSON.stringify({
        error: 'Not the leader',
        currentRole: node.role,
        leaderId: node.leaderId || 'Unknown',
      }));
      return;
    }

    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const value = JSON.parse(body);
        node.set(key, value);
        res.writeHead(200);
        res.end(JSON.stringify({ success: true, key, value, leaderId: node.nodeId }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });
    return;
  }

  // Route : DELETE /key/{key} - Supprimer une clé (leader uniquement)
  if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);

    if (node.role !== 'leader') {
      res.writeHead(503);
      res.end(JSON.stringify({
        error: 'Not the leader',
        currentRole: node.role,
        leaderId: node.leaderId || 'Unknown',
      }));
      return;
    }

    const existed = node.delete(key);
    if (existed) {
      res.writeHead(200);
      res.end(JSON.stringify({ success: true, key, leaderId: node.nodeId }));
    } else {
      res.writeHead(404);
      res.end(JSON.stringify({ error: 'Key not found', key }));
    }
    return;
  }

  // 404
  res.writeHead(404);
  res.end(JSON.stringify({ error: 'Not found' }));
});

server.listen(config.port, () => {
  console.log(`[${config.nodeId}] Store Node écoutant sur le port ${config.port}`);
  console.log(`[${config.nodeId}] Pairs : ${config.peers.join(', ') || 'none'}`);
  console.log(`[${config.nodeId}] Points de terminaison disponibles :`);
  console.log(`  GET  /status          - Statut et rôle du nœud`);
  console.log(`  GET  /key/{key}       - Obtenir une valeur`);
  console.log(`  PUT  /key/{key}       - Définir une valeur (leader uniquement)`);
  console.log(`  DEL  /key/{key}       - Supprimer une clé (leader uniquement)`);
});

replicated-store-ts/package.json

{
  "name": "replicated-store-ts",
  "version": "1.0.0",
  "description": "Replicated key-value store with leader election in TypeScript",
  "main": "dist/node.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/node.js",
    "dev": "ts-node src/node.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.9.0"
  }
}

replicated-store-ts/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

replicated-store-ts/Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 4000

CMD ["npm", "start"]

Implémentation Python

replicated-store-py/src/node.py

import os
import json
import time
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
from urllib.error import URLError

class StoreNode:
    """Nœud de magasin répliqué avec élection de leader."""

    def __init__(self, node_id: str, peers: List[str]):
        self.node_id = node_id
        self.role: str = 'follower'  # leader, follower, candidate
        self.term = 0
        self.data: Dict[str, Any] = {}
        self.peers = peers
        self.leader_id: Optional[str] = None
        self.last_heartbeat = time.time()

        # Configuration
        self.heartbeat_interval = 2.0  # secondes
        self.election_timeout = 6.0     # secondes

        # Démarrer le timer d'élection
        self.start_election_timer()

        # Démarrer le thread de heartbeat
        self.start_heartbeat_thread()

    def start_election_timer(self):
        """Démarrer le timer de timeout d'élection."""
        def election_timer():
            while True:
                time.sleep(1)
                time_since = time.time() - self.last_heartbeat
                if time_since > self.election_timeout and self.role != 'leader':
                    print(f"[{self.node_id}] Election timeout ! Démarrage de l'élection...")
                    self.start_election()

        thread = threading.Thread(target=election_timer, daemon=True)
        thread.start()

    def start_election(self):
        """Démarrer l'élection de leader (le plus simple : l'ID le plus bas gagne)."""
        self.term += 1
        self.role = 'candidate'

        # Stratégie simple : le nœud avec l'ID le plus bas devient leader
        all_nodes = sorted([self.node_id] + self.peers)
        lowest_node = all_nodes[0]

        if self.node_id == lowest_node:
            self.become_leader()
        else:
            self.role = 'follower'
            self.leader_id = lowest_node
            print(f"[{self.node_id}] En attente de {lowest_node} pour devenir leader")

    def become_leader(self):
        """Devenir le leader."""
        self.role = 'leader'
        self.leader_id = self.node_id
        print(f"[{self.node_id}] 👑 Devenu LEADER pour le terme {self.term}")

        # Répliquer immédiatement aux suiveurs
        self.replicate_to_followers()

    def start_heartbeat_thread(self):
        """Démarrer le heartbeat vers les suiveurs."""
        def heartbeat_loop():
            while True:
                time.sleep(self.heartbeat_interval)
                if self.role == 'leader':
                    self.send_heartbeat()

        thread = threading.Thread(target=heartbeat_loop, daemon=True)
        thread.start()

    def send_heartbeat(self):
        """Envoyer le heartbeat à tous les suiveurs."""
        heartbeat = {
            'type': 'heartbeat',
            'leader_id': self.node_id,
            'term': self.term,
            'timestamp': int(time.time() * 1000),
        }

        for peer in self.peers:
            try:
                self.send_to_peer(peer, '/internal/heartbeat', heartbeat)
            except Exception as e:
                print(f"[{self.node_id}] Échec de l'envoi du heartbeat à {peer} : {e}")

    def replicate_to_followers(self):
        """Répliquer les données à tous les suiveurs."""
        message = {
            'type': 'replicate',
            'leader_id': self.node_id,
            'term': self.term,
            'data': self.data,
        }

        for peer in self.peers:
            try:
                self.send_to_peer(peer, '/internal/replicate', message)
            except Exception as e:
                print(f"[{self.node_id}] Réplication échouée vers {peer} : {e}")

    def handle_heartbeat(self, heartbeat: dict):
        """Gérer le heartbeat du leader."""
        if heartbeat['term'] >= self.term:
            self.term = heartbeat['term']
            self.last_heartbeat = time.time()
            self.leader_id = heartbeat['leader_id']

            if self.role != 'follower':
                print(f"[{self.node_id}] Rétrogradation en suiveur, terme {self.term}")
            self.role = 'follower'

    def handle_replication(self, message: dict):
        """Gérer la réplication du leader."""
        if message['term'] >= self.term:
            self.term = message['term']
            self.leader_id = message['leader_id']
            self.role = 'follower'
            self.last_heartbeat = time.time()

            # Fusionner les données répliquées
            self.data.update(message['data'])
            print(f"[{self.node_id}] {len(message['data'])} clés répliquées depuis le leader")

    def send_to_peer(self, peer_url: str, path: str, data: dict) -> None:
        """Envoyer des données à un nœud pair."""
        url = f"{peer_url}{path}"
        body = json.dumps(data).encode('utf-8')

        req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
        with urlopen(req, timeout=1) as response:
            if response.status != 200:
                raise Exception(f"Status {response.status}")

    def set(self, key: str, value: Any) -> bool:
        """Définir une paire clé-valeur (seulement sur le leader)."""
        if self.role != 'leader':
            return False

        self.data[key] = value
        print(f"[{self.node_id}] SET {key} = {json.dumps(value)}")

        # Répliquer aux suiveurs
        self.replicate_to_followers()

        return True

    def get(self, key: str) -> Any:
        """Obtenir une valeur par clé."""
        value = self.data.get(key)
        print(f"[{self.node_id}] GET {key} => {json.dumps(value) if value is not None else 'null'}")
        return value

    def delete(self, key: str) -> bool:
        """Supprimer une clé (seulement sur le leader)."""
        if self.role != 'leader':
            return False

        existed = key in self.data
        if existed:
            del self.data[key]

        print(f"[{self.node_id}] DELETE {key} => {'success' if existed else 'not found'}")

        # Répliquer aux suiveurs
        self.replicate_to_followers()

        return existed

    def get_status(self) -> dict:
        """Obtenir le statut du nœud."""
        return {
            'node_id': self.node_id,
            'role': self.role,
            'term': self.term,
            'leader_id': self.leader_id,
            'total_keys': len(self.data),
            'keys': list(self.data.keys()),
        }


# Créer le nœud
config = {
    'node_id': os.environ.get('NODE_ID', 'node-1'),
    'port': int(os.environ.get('PORT', '4000')),
    'peers': [p for p in os.environ.get('PEERS', '').split(',') if p],
}

node = StoreNode(config['node_id'], config['peers'])


class NodeHandler(BaseHTTPRequestHandler):
    """Gestionnaire de requêtes HTTP pour le nœud de magasin."""

    def send_json_response(self, status: int, data: dict):
        """Envoyer une réponse JSON."""
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def do_OPTIONS(self):
        """Gérer le pré-vol CORS."""
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def do_POST(self):
        """Gérer les requêtes POST."""
        parsed = urlparse(self.path)

        # POST /internal/heartbeat
        if parsed.path == '/internal/heartbeat':
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')

            try:
                heartbeat = json.loads(body)
                node.handle_heartbeat(heartbeat)
                self.send_json_response(200, {'success': True})
            except (json.JSONDecodeError, KeyError):
                self.send_json_response(400, {'error': 'Invalid request'})
            return

        # POST /internal/replicate
        if parsed.path == '/internal/replicate':
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')

            try:
                message = json.loads(body)
                node.handle_replication(message)
                self.send_json_response(200, {'success': True})
            except (json.JSONDecodeError, KeyError):
                self.send_json_response(400, {'error': 'Invalid request'})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_GET(self):
        """Gérer les requêtes GET."""
        parsed = urlparse(self.path)

        # GET /status
        if parsed.path == '/status':
            self.send_json_response(200, node.get_status())
            return

        # GET /key/{key}
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]  # Retirer '/key/'
            value = node.get(key)

            if value is not None:
                self.send_json_response(200, {'key': key, 'value': value, 'node_role': node.role})
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_PUT(self):
        """Gérer les requêtes POST (définir une valeur)."""
        parsed = urlparse(self.path)

        # PUT /key/{key}
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]

            if node.role != 'leader':
                self.send_json_response(503, {
                    'error': 'Not the leader',
                    'current_role': node.role,
                    'leader_id': node.leader_id or 'Unknown',
                })
                return

            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')

            try:
                value = json.loads(body)
                node.set(key, value)
                self.send_json_response(200, {'success': True, 'key': key, 'value': value, 'leader_id': node.node_id})
            except json.JSONDecodeError:
                self.send_json_response(400, {'error': 'Invalid JSON'})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_DELETE(self):
        """Gérer les requêtes DELETE."""
        parsed = urlparse(self.path)

        # DELETE /key/{key}
        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]

            if node.role != 'leader':
                self.send_json_response(503, {
                    'error': 'Not the leader',
                    'current_role': node.role,
                    'leader_id': node.leader_id or 'Unknown',
                })
                return

            existed = node.delete(key)
            if existed:
                self.send_json_response(200, {'success': True, 'key': key, 'leader_id': node.node_id})
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def log_message(self, format, *args):
        """Supprimer la journalisation par défaut."""
        pass


def run_server(port: int):
    """Démarrer le serveur HTTP."""
    server_address = ('', port)
    httpd = HTTPServer(server_address, NodeHandler)
    print(f"[{config['node_id']}] Store Node écoutant sur le port {port}")
    print(f"[{config['node_id']}] Pairs : {', '.join(config['peers']) or 'none'}")
    print(f"[{config['node_id']}] Points de terminaison disponibles :")
    print(f"  GET  /status          - Statut et rôle du nœud")
    print(f"  GET  /key/{{key}}       - Obtenir une valeur")
    print(f"  PUT  /key/{{key}}       - Définir une valeur (leader uniquement)")
    print(f"  DEL  /key/{{key}}       - Supprimer une clé (leader uniquement)")
    httpd.serve_forever()


if __name__ == '__main__':
    run_server(config['port'])

replicated-store-py/requirements.txt

# Pas de dépendances externes - utilise uniquement la bibliothèque standard

replicated-store-py/Dockerfile

FROM python:3.11-alpine

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 4000

CMD ["python", "src/node.py"]

Configuration Docker Compose

Version TypeScript

examples/02-store/ts/docker-compose.yml

version: '3.8'

services:
  node1:
    build: .
    container_name: store-ts-node1
    ports:
      - "4001:4000"
    environment:
      - NODE_ID=node-1
      - PORT=4000
      - PEERS=http://node2:4000,http://node3:4000
    networks:
      - store-network

  node2:
    build: .
    container_name: store-ts-node2
    ports:
      - "4002:4000"
    environment:
      - NODE_ID=node-2
      - PORT=4000
      - PEERS=http://node1:4000,http://node3:4000
    networks:
      - store-network

  node3:
    build: .
    container_name: store-ts-node3
    ports:
      - "4003:4000"
    environment:
      - NODE_ID=node-3
      - PORT=4000
      - PEERS=http://node1:4000,http://node2:4000
    networks:
      - store-network

networks:
  store-network:
    driver: bridge

Version Python

examples/02-store/py/docker-compose.yml

version: '3.8'

services:
  node1:
    build: .
    container_name: store-py-node1
    ports:
      - "4001:4000"
    environment:
      - NODE_ID=node-1
      - PORT=4000
      - PEERS=http://node2:4000,http://node3:4000
    networks:
      - store-network

  node2:
    build: .
    container_name: store-py-node2
    ports:
      - "4002:4000"
    environment:
      - NODE_ID=node-2
      - PORT=4000
      - PEERS=http://node1:4000,http://node3:4000
    networks:
      - store-network

  node3:
    build: .
    container_name: store-py-node3
    ports:
      - "4003:4000"
    environment:
      - NODE_ID=node-3
      - PORT=4000
      - PEERS=http://node1:4000,http://node2:4000
    networks:
      - store-network

networks:
  store-network:
    driver: bridge

Exécution de l'Exemple

Étape 1 : Démarrer le Cluster à 3 Nœuds

TypeScript :

cd distributed-systems-course/examples/02-store/ts
docker-compose up --build

Python :

cd distributed-systems-course/examples/02-store/py
docker-compose up --build

Vous devriez voir l'élection de leader se produire automatiquement :

store-ts-node1 | [node-1] Store Node écoutant sur le port 4000
store-ts-node2 | [node-2] Store Node écoutant sur le port 4000
store-ts-node3 | [node-3] Store Node écoutant sur le port 4000
store-ts-node1 | [node-1] 👑 Devenu LEADER pour le terme 1
store-ts-node2 | [node-2] En attente de node-1 pour devenir leader
store-ts-node3 | [node-3] En attente de node-1 pour devenir leader

Étape 2 : Vérifier le Statut des Nœuds

# Vérifier tous les nœuds
curl http://localhost:4001/status
curl http://localhost:4002/status
curl http://localhost:4003/status

Réponse du node-1 (leader) :

{
  "nodeId": "node-1",
  "role": "leader",
  "term": 1,
  "leaderId": "node-1",
  "totalKeys": 0,
  "keys": []
}

Réponse du node-2 (suiveur) :

{
  "nodeId": "node-2",
  "role": "follower",
  "term": 1,
  "leaderId": "node-1",
  "totalKeys": 0,
  "keys": []
}

Étape 3 : Écrire au Leader

# Écrire au leader (node-1)
curl -X PUT http://localhost:4001/key/name \
  -H "Content-Type: application/json" \
  -d '"Alice"'

curl -X PUT http://localhost:4001/key/age \
  -H "Content-Type: application/json" \
  -d '30'

curl -X PUT http://localhost:4001/key/city \
  -H "Content-Type: application/json" \
  -d '"NYC"'

Réponse :

{
  "success": true,
  "key": "name",
  "value": "Alice",
  "leaderId": "node-1"
}

Étape 4 : Lire depuis les Suiveurs

Les données devraient être répliquées à tous les suiveurs :

curl http://localhost:4002/key/name
curl http://localhost:4003/key/city

Réponse :

{
  "key": "name",
  "value": "Alice",
  "nodeRole": "follower"
}

Étape 5 : Essayer d'Écrire à un Suiveur (Devrait Échouer)

curl -X PUT http://localhost:4002/key/test \
  -H "Content-Type: application/json" \
  -d '"should fail"'

Réponse :

{
  "error": "Not the leader",
  "currentRole": "follower",
  "leaderId": "node-1"
}

Étape 6 : Simuler une Panne de Leader

# Dans un terminal séparé, arrêter le leader
docker-compose stop node1

# Vérifier le statut de node-2 - devrait devenir le nouveau leader
curl http://localhost:4002/status

Après quelques secondes :

store-ts-node2 | [node-2] Election timeout ! Démarrage de l'élection...
store-ts-node2 | [node-2] 👑 Devenu LEADER pour le terme 2
store-ts-node3 | [node-3] En attente de node-2 pour devenir leader

Étape 7 : Écrire au Nouveau Leader

# Maintenant node-2 est le leader
curl -X PUT http://localhost:4002/key/newleader \
  -H "Content-Type: application/json" \
  -d '"node-2"'

Étape 8 : Redémarrer l'Ancien Leader

# Redémarrer node-1
docker-compose start node1

# Vérifier le statut - devrait devenir suiveur
curl http://localhost:4001/status

Réponse :

{
  "nodeId": "node-1",
  "role": "follower",
  "term": 2,
  "leaderId": "node-2",
  ...
}

Architecture du Système

graph TB
    subgraph "Magasin Répliqué à 3 Nœuds"
        Clients["Clients"]

        N1["Node 1<br/>👑 Leader"]
        N2["Node 2<br/>Suiveur"]
        N3["Node 3<br/>Suiveur"]

        Clients -->|"Write"| N1
        Clients -->|"Read"| N1
        Clients -->|"Read"| N2
        Clients -->|"Read"| N3

        N1 <-->|"Heartbeat<br/>Réplication"| N2
        N1 <-->|"Heartbeat<br/>Réplication"| N3
    end

    style N1 fill:#6f6,stroke:#333,stroke-width:3px

Exercices

Exercice 1 : Tester la Tolérance aux Pannes

  1. Démarrer le cluster et écrire quelques données
  2. Arrêter différents nœuds un par un
  3. Vérifier que le système continue de fonctionner
  4. Que se passe-t-il lorsque vous arrêtez 2 nœuds sur 3 ?

Exercice 2 : Observer le Délai de Réplication

  1. Ajouter un petit délai (par ex. 100ms) à la réplication
  2. Écrire des données au leader
  3. Lire immédiatement depuis un suiveur
  4. Que voyez-vous ? Cela démontre la cohérence événementielle.

Exercice 3 : Améliorer l'Élection de Leader

L'élection actuelle est très simple. Essayez de l'améliorer :

  1. Ajouter des timeouts d'élection aléatoires (comme Raft)
  2. Implémenter un vrai vote (pas seulement le plus petit ID)
  3. Ajouter un pré-vote pour éviter de perturber le leader actuel

Résumé

Points Clés à Retenir

  1. La Réplication copie les données sur plusieurs nœuds pour la tolérance aux pannes
  2. La Réplication à leader unique est simple mais toutes les écritures passent par le leader
  3. L'élection de leader assure qu'un nouveau leader est choisi quand le leader actuel tombe en panne
  4. La Réplication asynchrone est rapide mais peut perdre des données en cas de panne du leader
  5. La Cohérence lecture-après-écriture n'est PAS garantie lors de la lecture depuis les suiveurs

Compromis

ApprocheAvantagesInconvénients
Leader uniqueSimple, cohérence forteLe leader est un goulot d'étranglement, point de défaillance unique
Multi-leaderPas de goulot d'étranglement, écritures n'importe oùRésolution de conflits complexe
Réplication synchroneAucune perte de donnéesÉcritures lentes, bloquant
Réplication asynchroneÉcritures rapidesPerte de données possible, lectures périmées

Vérifiez Votre Compréhension

  • Pourquoi répliquons-nous les données ?
  • Quelle est la différence entre leader et suiveur ?
  • Que se passe-t-il lorsqu'un client essaie d'écrire à un suiveur ?
  • Comment fonctionne l'élection de leader dans notre implémentation ?
  • Quel est le compromis entre la réplication synchrone et asynchrone ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.

Suite

Nous avons une réplication fonctionnelle, mais notre modèle de cohérence est basique. Explorons les niveaux de cohérence : Modèles de Cohérence (Session 5)

Modèles de Cohérence

Session 5 - Session complète

Objectifs d'Apprentissage

  • Comprendre les différents modèles de cohérence dans les systèmes distribués
  • Apprendre les compromis entre la cohérence forte et la cohérence événementielle
  • Implémenter des niveaux de cohérence configurables dans un magasin répliqué
  • Expérimenter les effets des niveaux de cohérence à travers des exercices pratiques

Qu'est-ce que la Cohérence ?

Dans un magasin répliqué, la cohérence définit les garanties que vous avez sur les données que vous lisez. Lorsque les données sont copiées sur plusieurs nœuds, vous ne verrez pas toujours l'écriture la plus récente immédiatement.

graph TB
    subgraph "Une Écriture se Produit"
        C[Client]
        L[Leader]
        L -->|Write "name = Alice"| L
    end

    subgraph "Mais Que Lisez-Vous ?"
        F1[Suiveur 1<br/>name = Alice]
        F2[Suiveur 2<br/>name = ???]
        F3[Suiveur 3<br/>name = ???]

        C -->|Read| F1
        C -->|Read| F2
        C -->|Read| F3
    end

La question : Si vous lisez depuis un suiveur, verrez-vous "Alice" ou l'ancienne valeur ?

La réponse dépend de votre modèle de cohérence.

Spectre de Cohérence

Les modèles de cohérence existent sur un spectre du plus fort au plus faible :

graph LR
    A[Cohérence<br/>Forte]
    B[Read Your Writes]
    C[Lectures Monotones]
    D[Cohérence Causale]
    E[Cohérence<br/>Événementielle]

    A ====> B ====> C ====> D ====> E

    style A fill:#6f6
    style B fill:#9f6
    style C fill:#cf6
    style D fill:#ff6
    style E fill:#f96

Cohérence Forte

Définition : Chaque lecture reçoit l'écriture la plus récente ou une erreur.

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F as Suiveur

    Note over C,F: Le temps s'écoule vers le bas

    C->>L: SET name = "Alice"
    L->>L: Écriture confirmée

    Note over C,F: La cohérence forte nécessite :
    Note over C,F: Attendre la réplication...

    L->>F: Répliquer : name = "Alice"
    F->>L: ACK

    L->>C: Réponse : Success

    C->>F: GET name
    F->>C: "Alice" (toujours à jour !)

Caractéristiques :

  • Les lecteurs voient toujours les données les plus récentes
  • Aucune lecture périmée possible
  • Performances plus lentes (doit attendre la réplication)
  • Modèle mental simple

Quand l'utiliser : Transactions financières, gestion des stocks, opérations critiques

Cohérence Événementielle

Définition : Si aucune nouvelle mise à jour n'est faite, éventuellement tous les accès retourneront la dernière valeur mise à jour.

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F1 as Suiveur 1
    participant F2 as Suiveur 2

    Note over C,F2: Le temps s'écoule vers le bas

    C->>L: SET name = "Alice"
    L->>C: Réponse : Success (immédiatement !)

    Note over C,F2: Le leader n'a pas encore répliqué...

    C->>F1: GET name
    F1->>C: "Alice" (répliqué !)

    C->>F2: GET name
    F2->>C: "Bob" (périmé !)

    Note over C,F2: Un moment plus tard...

    L->>F2: Répliquer : name = "Alice"

    C->>F2: GET name
    F2->>C: "Alice" (mis à jour !)

Caractéristiques :

  • Les lectures sont rapides (pas d'attente de réplication)
  • Vous pouvez voir des données périmées
  • Éventuellement, tous les nœuds convergent
  • Modèle mental plus complexe

Quand l'utiliser : Flux de médias sociaux, recommandations de produits, analyses

Cohérence Read-Your-Writes

Un terrain d'entente : vous voyez toujours vos propres écritures, mais ne voyez pas forcément les écritures des autres immédiatement.

sequenceDiagram
    participant C1 as Client 1
    participant C2 as Client 2
    participant L as Leader
    participant F as Suiveur

    C1->>L: SET name = "Alice"
    L->>C1: Success

    C1->>F: GET name
    Note over C1,F: Read-your-writes:<br/>C1 voit "Alice"
    F->>C1: "Alice"

    C2->>F: GET name
    Note over C2,F: Peut voir des données périmées
    F->>C2: "Bob" (périmé !)

Le Théorème CAP Réexaminé

Vous avez appris CAP dans la Session 4. Relions-le à la cohérence :

CombinaisonModèle de CohérenceSystèmes Exemple
CPCohérence forteZooKeeper, etcd, MongoDB (avec w:majority)
APCohérence événementielleCassandra, DynamoDB, CouchDB
CA (impossible à grande échelle)Cohérence forteBases de données à nœud unique (SGBDR)

Cohérence Basée sur Quorum

Un moyen pratique de contrôler la cohérence est d'utiliser des quorums. Un quorum est une majorité de nœuds.

graph TB
    subgraph "Cluster à 3 Nœuds"
        N1[Nœud 1]
        N2[Nœud 2]
        N3[Nœud 3]

        Q[Quorum = 2<br/>⌈3/2⌉ = 2]
    end

    N1 -.-> Q
    N2 -.-> Q
    N3 -.-> Q

    style Q fill:#6f6,stroke:#333,stroke-width:3px

Quorum d'Écriture (W)

Nombre de nœuds qui doivent acquitter une écriture :

W > N/2  → Cohérence forte (majorité)
W = 1    → Rapide mais cohérence faible
W = N    → La plus forte mais la plus lente

Quorum de Lecture (R)

Nombre de nœuds à interroger et comparer pour une lecture :

R + W > N  → Cohérence forte garantie
R + W ≤ N  → Cohérence événementielle

Niveaux de Cohérence

R + WCohérencePerformanceCas d'Usage
N + 1 > N (impossible)La plus forteLenteDonnées critiques
R + W > NForteMoyenneBanque, stocks
R + W ≤ NÉvénementielleRapideMédias sociaux, cache

Implémentation

Nous allons étendre notre magasin répliqué de la Session 4 pour supporter des niveaux de cohérence configurables.

Implémentation TypeScript

Structure du Projet :

consistent-store-ts/
├── package.json
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── src/
    └── node.ts       # Nœud avec cohérence configurable

consistent-store-ts/src/node.ts

import http from 'http';

/**
 * Configuration du nœud
 */
const config = {
  nodeId: process.env.NODE_ID || 'node-1',
  port: parseInt(process.env.PORT || '4000'),
  peers: (process.env.PEERS || '').split(',').filter(Boolean),
  heartbeatInterval: 2000,
  electionTimeout: 6000,

  // Paramètres de cohérence
  writeQuorum: parseInt(process.env.WRITE_QUORUM || '2'),  // W
  readQuorum: parseInt(process.env.READ_QUORUM || '1'),    // R
};

type NodeRole = 'leader' | 'follower' | 'candidate';
type ConsistencyLevel = 'strong' | 'eventual' | 'read_your_writes';

/**
 * Nœud de Magasin Répliqué avec Cohérence Configurable
 */
class StoreNode {
  public nodeId: string;
  public role: NodeRole;
  public term: number;
  public data: Map<string, any>;
  public peers: string[];

  private leaderId: string | null;
  private lastHeartbeat: number;
  private heartbeatTimer?: NodeJS.Timeout;
  private electionTimer?: NodeJS.Timeout;
  private pendingWrites: Map<string, any[]>;  // Pour read-your-writes

  constructor(nodeId: string, peers: string[]) {
    this.nodeId = nodeId;
    this.role = 'follower';
    this.term = 0;
    this.data = new Map();
    this.peers = peers;
    this.leaderId = null;
    this.lastHeartbeat = Date.now();
    this.pendingWrites = new Map();

    this.startElectionTimer();
    this.startHeartbeat();
  }

  /**
   * Démarrer le timer de timeout d'élection
   */
  private startElectionTimer() {
    this.electionTimer = setTimeout(() => {
      const timeSinceHeartbeat = Date.now() - this.lastHeartbeat;
      if (timeSinceHeartbeat > config.electionTimeout && this.role !== 'leader') {
        console.log(`[${this.nodeId}] Election timeout ! Démarrage de l'élection...`);
        this.startElection();
      }
      this.startElectionTimer();
    }, config.electionTimeout);
  }

  /**
   * Démarrer l'élection de leader
   */
  private startElection() {
    this.term++;
    this.role = 'candidate';

    const allNodes = [this.nodeId, ...this.peers].sort();
    const lowestNode = allNodes[0];

    if (this.nodeId === lowestNode) {
      this.becomeLeader();
    } else {
      this.role = 'follower';
      this.leaderId = lowestNode;
      console.log(`[${this.nodeId}] En attente de ${lowestNode} pour devenir leader`);
    }
  }

  /**
   * Devenir leader
   */
  private becomeLeader() {
    this.role = 'leader';
    this.leaderId = this.nodeId;
    console.log(`[${this.nodeId}] 👑 Devenu LEADER pour le terme ${this.term}`);
    this.replicateToFollowers();
  }

  /**
   * Démarrer le heartbeat vers les suiveurs
   */
  private startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.role === 'leader') {
        this.sendHeartbeat();
      }
    }, config.heartbeatInterval);
  }

  /**
   * Envoyer le heartbeat à tous les suiveurs
   */
  private sendHeartbeat() {
    const heartbeat = {
      type: 'heartbeat',
      leaderId: this.nodeId,
      term: this.term,
      timestamp: Date.now(),
    };

    this.peers.forEach(peerUrl => {
      this.sendToPeer(peerUrl, '/internal/heartbeat', heartbeat)
        .catch(err => console.log(`[${this.nodeId}] Échec du heartbeat vers ${peerUrl}`));
    });
  }

  /**
   * Répliquer les données aux suiveurs avec accusé de réception du quorum
   */
  private async replicateToFollowers(): Promise<boolean> {
    const dataObj = Object.fromEntries(this.data);

    // Envoyer à tous les suiveurs en parallèle
    const promises = this.peers.map(peerUrl =>
      this.sendToPeer(peerUrl, '/internal/replicate', {
        type: 'replicate',
        leaderId: this.nodeId,
        term: this.term,
        data: dataObj,
      }).catch(err => {
        console.log(`[${this.nodeId}] Réplication échouée vers ${peerUrl}`);
        return false;
      })
    );

    // Attendre que tous se terminent
    const results = await Promise.all(promises);

    // Compter les succès (ce nœud compte comme 1)
    const successes = results.filter(r => r !== false).length + 1;

    // Vérifier si nous avons atteint le quorum d'écriture
    const achievedQuorum = successes >= config.writeQuorum;
    console.log(`[${this.nodeId}] Réplication : ${successes}/${this.peers.length + 1} nœuds (W=${config.writeQuorum})`);

    return achievedQuorum;
  }

  /**
   * Gérer le heartbeat du leader
   */
  handleHeartbeat(heartbeat: any) {
    if (heartbeat.term >= this.term) {
      this.term = heartbeat.term;
      this.lastHeartbeat = Date.now();
      this.leaderId = heartbeat.leaderId;
      if (this.role !== 'follower') {
        this.role = 'follower';
      }
    }
  }

  /**
   * Gérer la réplication du leader
   */
  handleReplication(message: any) {
    if (message.term >= this.term) {
      this.term = message.term;
      this.leaderId = message.leaderId;
      this.role = 'follower';
      this.lastHeartbeat = Date.now();

      Object.entries(message.data).forEach(([key, value]) => {
        this.data.set(key, value);
      });
    }
  }

  /**
   * Envoyer des données à un nœud pair
   */
  private async sendToPeer(peerUrl: string, path: string, data: any): Promise<void> {
    return new Promise((resolve, reject) => {
      const url = new URL(path, peerUrl);
      const options = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      };

      const req = http.request(url, options, (res) => {
        if (res.statusCode === 200) {
          resolve();
        } else {
          reject(new Error(`Status ${res.statusCode}`));
        }
      });

      req.on('error', reject);
      req.write(JSON.stringify(data));
      req.end();
    });
  }

  /**
   * Définir une paire clé-valeur avec accusé de réception du quorum
   */
  async set(key: string, value: any): Promise<{ success: boolean; achievedQuorum: boolean }> {
    if (this.role !== 'leader') {
      return { success: false, achievedQuorum: false };
    }

    this.data.set(key, value);
    console.log(`[${this.nodeId}] SET ${key} = ${JSON.stringify(value)}`);

    // Répliquer aux suiveurs
    const achievedQuorum = await this.replicateToFollowers();

    return { success: true, achievedQuorum };
  }

  /**
   * Obtenir une valeur avec cohérence configurable
   */
  async get(key: string, consistency: ConsistencyLevel = 'eventual'): Promise<any> {
    const localValue = this.data.get(key);

    // Pour la cohérence événementielle, retourner la valeur locale immédiatement
    if (consistency === 'eventual') {
      console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(localValue)} (événementielle)`);
      return localValue;
    }

    // Pour la cohérence forte, interroger un quorum de nœuds
    if (consistency === 'strong') {
      const values = await this.getFromQuorum(key);
      console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(values.latest)} (forte depuis ${values.responses} nœuds)`);
      return values.latest;
    }

    // Pour read-your-writes, vérifier les écritures en attente
    if (consistency === 'read_your_writes') {
      const pending = this.pendingWrites.get(key);
      const valueToReturn = pending && pending.length > 0 ? pending[pending.length - 1] : localValue;
      console.log(`[${this.nodeId}] GET ${key} => ${JSON.stringify(valueToReturn)} (read-your-writes)`);
      return valueToReturn;
    }

    return localValue;
  }

  /**
   * Interroger un quorum de nœuds et retourner la valeur la plus récente
   */
  private async getFromQuorum(key: string): Promise<{ latest: any; responses: number }> {
    // Interroger tous les pairs
    const promises = this.peers.map(peerUrl =>
      this.queryPeer(peerUrl, '/internal/get', { key })
        .then(result => ({ success: true, value: result.value, version: result.version || 0 }))
        .catch(err => {
          console.log(`[${this.nodeId}] Query échouée vers ${peerUrl}`);
          return { success: false, value: null, version: 0 };
        })
    );

    const results = await Promise.all(promises);

    // Ajouter la valeur locale
    results.push({
      success: true,
      value: this.data.get(key),
      version: this.data.has(key) ? 1 : 0,
    });

    // Compter les réponses réussies
    const successful = results.filter(r => r.success);

    // Retourner si nous avons le quorum de lecture
    if (successful.length >= config.readQuorum) {
      // Retourner la valeur la plus récente (version simple : première non-nulle)
      const latest = successful.find(r => r.value !== undefined)?.value;
      return { latest, responses: successful.length };
    }

    // Retour à la valeur locale
    return { latest: this.data.get(key), responses: successful.length };
  }

  /**
   * Interroger un pair pour une clé
   */
  private async queryPeer(peerUrl: string, path: string, data: any): Promise<any> {
    return new Promise((resolve, reject) => {
      const url = new URL(path, peerUrl);
      const options = {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      };

      const req = http.request(url, options, (res) => {
        let body = '';
        res.on('data', chunk => body += chunk);
        res.on('end', () => {
          if (res.statusCode === 200) {
            resolve(JSON.parse(body));
          } else {
            reject(new Error(`Status ${res.statusCode}`));
          }
        });
      });

      req.on('error', reject);
      req.write(JSON.stringify(data));
      req.end();
    });
  }

  /**
   * Supprimer une clé
   */
  async delete(key: string): Promise<{ success: boolean; achievedQuorum: boolean }> {
    if (this.role !== 'leader') {
      return { success: false, achievedQuorum: false };
    }

    const existed = this.data.delete(key);
    console.log(`[${this.nodeId}] DELETE ${key}`);

    await this.replicateToFollowers();

    return { success: existed, achievedQuorum: true };
  }

  /**
   * Obtenir le statut du nœud
   */
  getStatus() {
    return {
      nodeId: this.nodeId,
      role: this.role,
      term: this.term,
      leaderId: this.leaderId,
      totalKeys: this.data.size,
      keys: Array.from(this.data.keys()),
      config: {
        writeQuorum: config.writeQuorum,
        readQuorum: config.readQuorum,
        totalNodes: this.peers.length + 1,
      },
    };
  }
}

// Créer le nœud
const node = new StoreNode(config.nodeId, config.peers);

/**
 * Serveur HTTP
 */
const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  if (req.method === 'OPTIONS') {
    res.writeHead(200);
    res.end();
    return;
  }

  const url = new URL(req.url || '', `http://${req.headers.host}`);

  // Route : POST /internal/heartbeat
  if (req.method === 'POST' && url.pathname === '/internal/heartbeat') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const heartbeat = JSON.parse(body);
        node.handleHeartbeat(heartbeat);
        res.writeHead(200);
        res.end(JSON.stringify({ success: true }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid request' }));
      }
    });
    return;
  }

  // Route : POST /internal/replicate
  if (req.method === 'POST' && url.pathname === '/internal/replicate') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const message = JSON.parse(body);
        node.handleReplication(message);
        res.writeHead(200);
        res.end(JSON.stringify({ success: true }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid request' }));
      }
    });
    return;
  }

  // Route : POST /internal/get - Requête interne pour les lectures de quorum
  if (req.method === 'POST' && url.pathname === '/internal/get') {
    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const { key } = JSON.parse(body);
        const value = node.data.get(key);
        res.writeHead(200);
        res.end(JSON.stringify({ value, version: value !== undefined ? 1 : 0 }));
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid request' }));
      }
    });
    return;
  }

  // Route : GET /status
  if (req.method === 'GET' && url.pathname === '/status') {
    res.writeHead(200);
    res.end(JSON.stringify(node.getStatus()));
    return;
  }

  // Route : GET /key/{key}?consistency=strong|eventual|read_your_writes
  if (req.method === 'GET' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);
    const consistency = (url.searchParams.get('consistency') || 'eventual') as ConsistencyLevel;

    node.get(key, consistency).then(value => {
      if (value !== undefined) {
        res.writeHead(200);
        res.end(JSON.stringify({ key, value, nodeRole: node.role, consistency }));
      } else {
        res.writeHead(404);
        res.end(JSON.stringify({ error: 'Key not found', key }));
      }
    });
    return;
  }

  // Route : PUT /key/{key}
  if (req.method === 'PUT' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);

    if (node.role !== 'leader') {
      res.writeHead(503);
      res.end(JSON.stringify({
        error: 'Not the leader',
        currentRole: node.role,
        leaderId: node.leaderId || 'Unknown',
      }));
      return;
    }

    let body = '';
    req.on('data', chunk => body += chunk);
    req.on('end', () => {
      try {
        const value = JSON.parse(body);
        node.set(key, value).then(result => {
          res.writeHead(200);
          res.end(JSON.stringify({
            success: result.success,
            key,
            value,
            leaderId: node.nodeId,
            achievedQuorum: result.achievedQuorum,
            writeQuorum: config.writeQuorum,
          }));
        });
      } catch (error) {
        res.writeHead(400);
        res.end(JSON.stringify({ error: 'Invalid JSON' }));
      }
    });
    return;
  }

  // Route : DELETE /key/{key}
  if (req.method === 'DELETE' && url.pathname.startsWith('/key/')) {
    const key = url.pathname.slice(5);

    if (node.role !== 'leader') {
      res.writeHead(503);
      res.end(JSON.stringify({
        error: 'Not the leader',
        currentRole: node.role,
        leaderId: node.leaderId || 'Unknown',
      }));
      return;
    }

    node.delete(key).then(result => {
      if (result.success) {
        res.writeHead(200);
        res.end(JSON.stringify({ success: true, key, leaderId: node.nodeId }));
      } else {
        res.writeHead(404);
        res.end(JSON.stringify({ error: 'Key not found', key }));
      }
    });
    return;
  }

  // 404
  res.writeHead(404);
  res.end(JSON.stringify({ error: 'Not found' }));
});

server.listen(config.port, () => {
  console.log(`[${config.nodeId}] Consistent Store écoutant sur le port ${config.port}`);
  console.log(`[${config.nodeId}] Quorum d'Écriture (W) : ${config.writeQuorum}, Quorum de Lecture (R) : ${config.readQuorum}`);
  console.log(`[${config.nodeId}] Pairs : ${config.peers.join(', ') || 'none'}`);
  console.log(`[${config.nodeId}] Points de terminaison disponibles :`);
  console.log(`  GET  /status                         - Statut du nœud`);
  console.log(`  GET  /key/{key}?consistency=level   - Obtenir avec niveau de cohérence`);
  console.log(`  PUT  /key/{key}                      - Définir une valeur (leader uniquement)`);
  console.log(`  DEL  /key/{key}                      - Supprimer une clé (leader uniquement)`);
});

consistent-store-ts/package.json

{
  "name": "consistent-store-ts",
  "version": "1.0.0",
  "description": "Replicated key-value store with configurable consistency",
  "main": "dist/node.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/node.js",
    "dev": "ts-node src/node.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.9.0"
  }
}

consistent-store-ts/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"]
}

consistent-store-ts/Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 4000

CMD ["npm", "start"]

Implémentation Python

consistent-store-py/src/node.py

import os
import json
import time
import threading
import asyncio
from http.server import HTTPServer, BaseHTTPRequestHandler
from typing import Any, Dict, List, Optional, Literal
from urllib.parse import urlparse, parse_qs
from urllib.request import Request, urlopen
from urllib.error import URLError

ConsistencyLevel = Literal['strong', 'eventual', 'read_your_writes']

class StoreNode:
    """Nœud de magasin répliqué avec cohérence configurable."""

    def __init__(self, node_id: str, peers: List[str]):
        self.node_id = node_id
        self.role: str = 'follower'
        self.term = 0
        self.data: Dict[str, Any] = {}
        self.peers = peers
        self.leader_id: Optional[str] = None
        self.last_heartbeat = time.time()
        self.pending_writes: Dict[str, List[Any]] = {}

        # Configuration
        self.heartbeat_interval = 2.0
        self.election_timeout = 6.0
        self.write_quorum = int(os.environ.get('WRITE_QUORUM', '2'))
        self.read_quorum = int(os.environ.get('READ_QUORUM', '1'))

        # Démarrer les timers
        self.start_election_timer()
        self.start_heartbeat_thread()

    def start_election_timer(self):
        """Démarrer le timer de timeout d'élection."""
        def election_timer():
            while True:
                time.sleep(1)
                time_since = time.time() - self.last_heartbeat
                if time_since > self.election_timeout and self.role != 'leader':
                    print(f"[{self.node_id}] Election timeout ! Démarrage de l'élection...")
                    self.start_election()

        thread = threading.Thread(target=election_timer, daemon=True)
        thread.start()

    def start_election(self):
        """Démarrer l'élection de leader."""
        self.term += 1
        self.role = 'candidate'

        all_nodes = sorted([self.node_id] + self.peers)
        lowest_node = all_nodes[0]

        if self.node_id == lowest_node:
            self.become_leader()
        else:
            self.role = 'follower'
            self.leader_id = lowest_node
            print(f"[{self.node_id}] En attente de {lowest_node} pour devenir leader")

    def become_leader(self):
        """Devenir leader."""
        self.role = 'leader'
        self.leader_id = self.node_id
        print(f"[{self.node_id}] 👑 Devenu LEADER pour le terme {self.term}")
        self.replicate_to_followers()

    def start_heartbeat_thread(self):
        """Démarrer le heartbeat vers les suiveurs."""
        def heartbeat_loop():
            while True:
                time.sleep(self.heartbeat_interval)
                if self.role == 'leader':
                    self.send_heartbeat()

        thread = threading.Thread(target=heartbeat_loop, daemon=True)
        thread.start()

    def send_heartbeat(self):
        """Envoyer le heartbeat à tous les suiveurs."""
        heartbeat = {
            'type': 'heartbeat',
            'leader_id': self.node_id,
            'term': self.term,
            'timestamp': int(time.time() * 1000),
        }

        for peer in self.peers:
            try:
                self.send_to_peer(peer, '/internal/heartbeat', heartbeat)
            except Exception as e:
                print(f"[{self.node_id}] Échec du heartbeat vers {peer} : {e}")

    def replicate_to_followers(self) -> bool:
        """Répliquer les données aux suiveurs et vérifier le quorum."""
        message = {
            'type': 'replicate',
            'leader_id': self.node_id,
            'term': self.term,
            'data': self.data,
        }

        successes = 1  # Ce nœud compte

        for peer in self.peers:
            try:
                self.send_to_peer(peer, '/internal/replicate', message)
                successes += 1
            except Exception as e:
                print(f"[{self.node_id}] Réplication échouée vers {peer} : {e}")

        achieved_quorum = successes >= self.write_quorum
        print(f"[{self.node_id}] Réplication : {successes}/{len(self.peers) + 1} nœuds (W={self.write_quorum})")

        return achieved_quorum

    def handle_heartbeat(self, heartbeat: dict):
        """Gérer le heartbeat du leader."""
        if heartbeat['term'] >= self.term:
            self.term = heartbeat['term']
            self.last_heartbeat = time.time()
            self.leader_id = heartbeat['leader_id']
            if self.role != 'follower':
                self.role = 'follower'

    def handle_replication(self, message: dict):
        """Gérer la réplication du leader."""
        if message['term'] >= self.term:
            self.term = message['term']
            self.leader_id = message['leader_id']
            self.role = 'follower'
            self.last_heartbeat = time.time()
            self.data.update(message['data'])

    def send_to_peer(self, peer_url: str, path: str, data: dict) -> None:
        """Envoyer des données à un nœud pair."""
        url = f"{peer_url}{path}"
        body = json.dumps(data).encode('utf-8')

        req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
        with urlopen(req, timeout=1) as response:
            if response.status != 200:
                raise Exception(f"Status {response.status}")

    def set(self, key: str, value: Any) -> Dict[str, Any]:
        """Définir une paire clé-valeur avec accusé de réception du quorum."""
        if self.role != 'leader':
            return {'success': False, 'achieved_quorum': False}

        self.data[key] = value
        print(f"[{self.node_id}] SET {key} = {json.dumps(value)}")

        achieved_quorum = self.replicate_to_followers()

        return {'success': True, 'achieved_quorum': achieved_quorum}

    def get(self, key: str, consistency: ConsistencyLevel = 'eventual') -> Any:
        """Obtenir une valeur avec cohérence configurable."""
        local_value = self.data.get(key)

        if consistency == 'eventual':
            print(f"[{self.node_id}] GET {key} => {json.dumps(local_value)} (événementielle)")
            return local_value

        if consistency == 'strong':
            latest, responses = self.get_from_quorum(key)
            print(f"[{self.node_id}] GET {key} => {json.dumps(latest)} (forte depuis {responses} nœuds)")
            return latest

        if consistency == 'read_your_writes':
            pending = self.pending_writes.get(key, [])
            value_to_return = pending[-1] if pending else local_value
            print(f"[{self.node_id}] GET {key} => {json.dumps(value_to_return)} (read-your-writes)")
            return value_to_return

        return local_value

    def get_from_quorum(self, key: str) -> tuple:
        """Interroger un quorum de nœuds et retourner la valeur la plus récente."""
        results = []

        # Interroger tous les pairs
        for peer in self.peers:
            try:
                result = self.query_peer(peer, '/internal/get', {'key': key})
                results.append({
                    'success': True,
                    'value': result.get('value'),
                    'version': result.get('version', 0),
                })
            except Exception as e:
                print(f"[{self.node_id}] Query échouée vers {peer} : {e}")
                results.append({'success': False, 'value': None, 'version': 0})

        # Ajouter la valeur locale
        results.append({
            'success': True,
            'value': self.data.get(key),
            'version': 1 if key in self.data else 0,
        })

        # Filtrer les réponses réussies
        successful = [r for r in results if r['success']]

        if len(successful) >= self.read_quorum:
            # Retourner la première valeur non-nulle
            for r in successful:
                if r['value'] is not None:
                    return r['value'], len(successful)

        return self.data.get(key), len(successful)

    def query_peer(self, peer_url: str, path: str, data: dict) -> dict:
        """Interroger un pair pour une clé."""
        url = f"{peer_url}{path}"
        body = json.dumps(data).encode('utf-8')

        req = Request(url, data=body, headers={'Content-Type': 'application/json'}, method='POST')
        with urlopen(req, timeout=1) as response:
            if response.status == 200:
                return json.loads(response.read().decode('utf-8'))
            raise Exception(f"Status {response.status}")

    def delete(self, key: str) -> Dict[str, Any]:
        """Supprimer une clé."""
        if self.role != 'leader':
            return {'success': False, 'achieved_quorum': False}

        existed = key in self.data
        if existed:
            del self.data[key]

        print(f"[{self.node_id}] DELETE {key}")
        self.replicate_to_followers()

        return {'success': existed, 'achieved_quorum': True}

    def get_status(self) -> dict:
        """Obtenir le statut du nœud."""
        return {
            'node_id': self.node_id,
            'role': self.role,
            'term': self.term,
            'leader_id': self.leader_id,
            'total_keys': len(self.data),
            'keys': list(self.data.keys()),
            'config': {
                'write_quorum': self.write_quorum,
                'read_quorum': self.read_quorum,
                'total_nodes': len(self.peers) + 1,
            },
        }


# Créer le nœud
config = {
    'node_id': os.environ.get('NODE_ID', 'node-1'),
    'port': int(os.environ.get('PORT', '4000')),
    'peers': [p for p in os.environ.get('PEERS', '').split(',') if p],
}

node = StoreNode(config['node_id'], config['peers'])


class NodeHandler(BaseHTTPRequestHandler):
    """Gestionnaire de requêtes HTTP pour le nœud de magasin."""

    def send_json_response(self, status: int, data: dict):
        """Envoyer une réponse JSON."""
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def do_OPTIONS(self):
        """Gérer le pré-vol CORS."""
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.end_headers()

    def do_POST(self):
        """Gérer les requêtes POST."""
        parsed = urlparse(self.path)

        if parsed.path == '/internal/heartbeat':
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')
            try:
                heartbeat = json.loads(body)
                node.handle_heartbeat(heartbeat)
                self.send_json_response(200, {'success': True})
            except (json.JSONDecodeError, KeyError):
                self.send_json_response(400, {'error': 'Invalid request'})
            return

        if parsed.path == '/internal/replicate':
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')
            try:
                message = json.loads(body)
                node.handle_replication(message)
                self.send_json_response(200, {'success': True})
            except (json.JSONDecodeError, KeyError):
                self.send_json_response(400, {'error': 'Invalid request'})
            return

        if parsed.path == '/internal/get':
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')
            try:
                data = json.loads(body)
                key = data.get('key')
                value = node.data.get(key)
                self.send_json_response(200, {'value': value, 'version': 1 if value is not None else 0})
            except (json.JSONDecodeError, KeyError):
                self.send_json_response(400, {'error': 'Invalid request'})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_GET(self):
        """Gérer les requêtes GET."""
        parsed = urlparse(self.path)

        if parsed.path == '/status':
            self.send_json_response(200, node.get_status())
            return

        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]
            consistency = parsed.query.split('=')[-1] if '=' in parsed.query else 'eventual'

            if consistency not in ['strong', 'eventual', 'read_your_writes']:
                consistency = 'eventual'

            value = node.get(key, consistency)
            if value is not None:
                self.send_json_response(200, {
                    'key': key,
                    'value': value,
                    'node_role': node.role,
                    'consistency': consistency,
                })
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_PUT(self):
        """Gérer les requêtes PUT."""
        parsed = urlparse(self.path)

        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]

            if node.role != 'leader':
                self.send_json_response(503, {
                    'error': 'Not the leader',
                    'current_role': node.role,
                    'leader_id': node.leader_id or 'Unknown',
                })
                return

            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')

            try:
                value = json.loads(body)
                result = node.set(key, value)
                self.send_json_response(200, {
                    'success': result['success'],
                    'key': key,
                    'value': value,
                    'leader_id': node.node_id,
                    'achieved_quorum': result['achieved_quorum'],
                    'write_quorum': node.write_quorum,
                })
            except json.JSONDecodeError:
                self.send_json_response(400, {'error': 'Invalid JSON'})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def do_DELETE(self):
        """Gérer les requêtes DELETE."""
        parsed = urlparse(self.path)

        if parsed.path.startswith('/key/'):
            key = parsed.path[5:]

            if node.role != 'leader':
                self.send_json_response(503, {
                    'error': 'Not the leader',
                    'current_role': node.role,
                    'leader_id': node.leader_id or 'Unknown',
                })
                return

            result = node.delete(key)
            if result['success']:
                self.send_json_response(200, {'success': True, 'key': key, 'leader_id': node.node_id})
            else:
                self.send_json_response(404, {'error': 'Key not found', 'key': key})
            return

        self.send_json_response(404, {'error': 'Not found'})

    def log_message(self, format, *args):
        """Supprimer la journalisation par défaut."""
        pass


def run_server(port: int):
    """Démarrer le serveur HTTP."""
    server_address = ('', port)
    httpd = HTTPServer(server_address, NodeHandler)
    print(f"[{config['node_id']}] Consistent Store écoutant sur le port {port}")
    print(f"[{config['node_id']}] Quorum d'Écriture (W) : {node.write_quorum}, Quorum de Lecture (R) : {node.read_quorum}")
    print(f"[{config['node_id']}] Pairs : {', '.join(config['peers']) or 'none'}")
    print(f"[{config['node_id']}] Points de terminaison disponibles :")
    print(f"  GET  /status                         - Statut du nœud")
    print(f"  GET  /key/{{key}}?consistency=level   - Obtenir avec niveau de cohérence")
    print(f"  PUT  /key/{{key}}                      - Définir une valeur (leader uniquement)")
    print(f"  DEL  /key/{{key}}                      - Supprimer une clé (leader uniquement)")
    httpd.serve_forever()


if __name__ == '__main__':
    run_server(config['port'])

consistent-store-py/requirements.txt

# Pas de dépendances externes - utilise uniquement la bibliothèque standard

consistent-store-py/Dockerfile

FROM python:3.11-alpine

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 4000

CMD ["python", "src/node.py"]

Configuration Docker Compose

Version TypeScript

examples/03-consistent-store/ts/docker-compose.yml

version: '3.8'

services:
  node1:
    build: .
    container_name: consistent-ts-node1
    ports:
      - "4001:4000"
    environment:
      - NODE_ID=node-1
      - PORT=4000
      - PEERS=http://node2:4000,http://node3:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

  node2:
    build: .
    container_name: consistent-ts-node2
    ports:
      - "4002:4000"
    environment:
      - NODE_ID=node-2
      - PORT=4000
      - PEERS=http://node1:4000,http://node3:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

  node3:
    build: .
    container_name: consistent-ts-node3
    ports:
      - "4003:4000"
    environment:
      - NODE_ID=node-3
      - PORT=4000
      - PEERS=http://node1:4000,http://node2:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

networks:
  consistent-network:
    driver: bridge

Version Python

examples/03-consistent-store/py/docker-compose.yml

version: '3.8'

services:
  node1:
    build: .
    container_name: consistent-py-node1
    ports:
      - "4001:4000"
    environment:
      - NODE_ID=node-1
      - PORT=4000
      - PEERS=http://node2:4000,http://node3:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

  node2:
    build: .
    container_name: consistent-py-node2
    ports:
      - "4002:4000"
    environment:
      - NODE_ID=node-2
      - PORT=4000
      - PEERS=http://node1:4000,http://node3:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

  node3:
    build: .
    container_name: consistent-py-node3
    ports:
      - "4003:4000"
    environment:
      - NODE_ID=node-3
      - PORT=4000
      - PEERS=http://node1:4000,http://node2:4000
      - WRITE_QUORUM=2
      - READ_QUORUM=1
    networks:
      - consistent-network

networks:
  consistent-network:
    driver: bridge

Exécution de l'Exemple

Étape 1 : Démarrer le Cluster

TypeScript :

cd distributed-systems-course/examples/03-consistent-store/ts
docker-compose up --build

Python :

cd distributed-systems-course/examples/03-consistent-store/py
docker-compose up --build

Vous devriez voir :

consistent-ts-node1 | [node-1] 👑 Devenu LEADER pour le terme 1
consistent-ts-node1 | [node-1] Quorum d'Écriture (W) : 2, Quorum de Lecture (R) : 1
consistent-ts-node2 | [node-2] En attente de node-1 pour devenir leader
consistent-ts-node3 | [node-3] En attente de node-1 pour devenir leader

Étape 2 : Tester la Cohérence Événementielle (Défaut)

# Écrire au leader
curl -X PUT http://localhost:4001/key/name \
  -H "Content-Type: application/json" \
  -d '"Alice"'

# Lire immédiatement depuis le suiveur (cohérence événementielle)
curl http://localhost:4002/key/name

Vous pourriez voir :

  • Immédiatement après l'écriture : null (le suiveur n'a pas encore reçu la réplication)
  • Un moment plus tard : "Alice" (le suiveur a récupéré)

Étape 3 : Tester la Cohérence Forte

# Lire avec cohérence forte (attend le quorum)
curl "http://localhost:4002/key/name?consistency=strong"

Cela interroge plusieurs nœuds et retourne la valeur confirmée la plus récente.

Étape 4 : Observer le Comportement du Quorum

Vérifiez le statut pour voir vos paramètres de quorum :

curl http://localhost:4001/status

Réponse :

{
  "nodeId": "node-1",
  "role": "leader",
  "config": {
    "writeQuorum": 2,
    "readQuorum": 1,
    "totalNodes": 3
  }
}

Étape 5 : Tester Différents Paramètres de Quorum

Arrêtez docker-compose et modifiez les variables d'environnement :

Essayer W=3 (Le plus fort) :

environment:
  - WRITE_QUORUM=3
  - READ_QUORUM=1

Essayer W=1 (Le plus faible) :

environment:
  - WRITE_QUORUM=1
  - READ_QUORUM=1

Observez comment le système se comporte différemment avec chaque paramètre.

Comparaison de Cohérence

graph TB
    subgraph "Mêmes Données, Différents Niveaux de Cohérence"
        W[Write : name = Alice]

        subgraph "Cohérence Forte<br/>Lente mais Précise"
            S1[Nœud 1 : Alice]
            S2[Nœud 2 : Alice]
            S3[Nœud 3 : Alice]
            R1[Lire → Alice]
        end

        subgraph "Cohérence Événementielle<br/>Rapide mais Possiblement Périmée"
            E1[Nœud 1 : Alice]
            E2[Nœud 2 : Bob]
            E3[Nœud 3 : ???]
            R2[Lire → Bob ou ???]
        end
    end

    W --> S1
    W --> S2
    W --> S3
    W --> E1
    W -.->|retardé| E2
    W -.->|retardé| E3

    style R1 fill:#6f6
    style R2 fill:#f96

Exercices

Exercice 1 : Expérimenter la Cohérence Événementielle

  1. Démarrer le cluster
  2. Écrire une valeur au leader
  3. Immédiatement lire depuis un suiveur (dans les 100ms)
  4. Que voyez-vous ? Est-ce la nouvelle valeur ou l'ancienne ?

Exercice 2 : Comparer les Niveaux de Cohérence

Écrivez un script qui :

  1. Définit une clé à une nouvelle valeur
  2. Lit immédiatement avec consistency=eventual
  3. Lit immédiatement avec consistency=strong
  4. Compare les résultats

Exercice 3 : Ajuster le Quorum pour Différents Cas d'Usage

Pour chaque scénario, quels paramètres de quorum choisiriez-vous ?

ScénarioW (Écriture)R (Lecture)R + WCohérencePourquoi ?
Transfert de solde bancaire????
J'aime sur les médias sociaux????
Panier d'achat????
Vue du profil utilisateur????

Exercice 4 : Implémenter la Réparation de Lecture

Lorsqu'une lecture périmée est détectée, mettre à jour le nœud périmé avec la valeur la plus récente. Indice : Dans la lecture forte, si vous trouvez une valeur plus récente sur un nœud, envoyez-la aux nœuds avec des valeurs plus anciennes.

Résumé

Points Clés à Retenir

  1. La cohérence est un spectre de la forte à l'événementielle
  2. Cohérence forte = toujours voir les données les plus récentes, mais plus lent
  3. Cohérence événementielle = lectures rapides, mais peut voir des données périmées
  4. Configuration du quorum (W + R) contrôle le niveau de cohérence :
    • R + W > N → Cohérence forte
    • R + W ≤ N → Cohérence événementielle
  5. Compromis : Vous ne pouvez pas avoir à la fois la cohérence forte ET la haute disponibilité (théorème CAP)

Arbre de Décision de Cohérence

Besoin de lire les données les plus récentes immédiatement ?
├─ Oui → Utiliser la cohérence forte (R + W > N)
│  └─ Accepter des performances plus lentes
└─ Non → Utiliser la cohérence événementielle (R + W ≤ N)
   └─ Obtenir des lectures plus rapides, accepter un certain péremé

Exemples du Monde Réel

SystèmeCohérence par DéfautConfigurable ?
DynamoDBCohérence événementielleOui (paramètre ConsistentRead)
CassandraCohérence événementielleOui (niveau CONSISTENCY)
MongoDBForte (w:majority)Oui (writeConcern, readConcern)
CouchDBCohérence événementielleOui (paramètres r, w)
etcdForteNon (toujours forte)

Vérifiez Votre Compréhension

  • Quelle est la différence entre la cohérence forte et événementielle ?
  • Comment la configuration du quorum (R, W) affecte-t-elle la cohérence ?
  • Quand choisiriez-vous la cohérence événementielle plutôt que forte ?
  • Que garantit R + W > N ?
  • Pourquoi ne pouvons-nous pas avoir à la fois la cohérence forte et la haute disponibilité pendant les partitions ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront toute lacune dans vos connaissances.

Suite

Nous avons construit un magasin répliqué avec cohérence configurable. Ajoutons maintenant la communication en temps réel : WebSockets (Session 6)

WebSockets

Session 6, Partie 1 - 20 minutes

Objectifs d'apprentissage

  • Comprendre le protocole WebSocket et ses avantages par rapport à HTTP
  • Apprendre le cycle de vie d'une connexion WebSocket
  • Implémenter des serveurs et clients WebSocket en TypeScript et Python
  • Gérer la gestion des connexions et les scénarios d'erreur

Introduction

Dans les sessions précédentes, nous avons construit des systèmes utilisant HTTP - un protocole requête-réponse. Le client demande, le serveur répond. Mais que se passe-t-il si nous avons besoin d'une communication en temps réel, bidirectionnelle ?

Voici les WebSockets : un protocole qui permet la communication full-duplex sur une seule connexion TCP.

sequenceDiagram
    participant Client
    participant Server

    Note over Client,Server: HTTP Requête-Réponse (Traditionnel)
    Client->>Server: GET /data
    Server-->>Client: Response
    Client->>Server: GET /data
    Server-->>Client: Response

    Note over Client,Server: WebSocket (Temps Réel)
    Client->>Server: HTTP Upgrade Request
    Server-->>Client: 101 Switching Protocols
    Client->>Server: Message 1
    Server-->>Client: Message 2
    Client->>Server: Message 3
    Server-->>Client: Message 4
    Client->>Server: Message 5

WebSocket vs HTTP

AspectHTTPWebSocket
CommunicationHalf-duplex (requête-réponse)Full-duplex (bidirectionnelle)
ConnexionNouvelle connexion par requêteConnexion persistante
LatencePlus élevée (surcharge HTTP)Plus faible (trames, non paquets)
ÉtatSans état (stateless)Connexion avec état (stateful)
Push serveurNécessite polling/SSESupport natif du push

Quand utiliser les WebSockets

Idéal pour :

  • Les applications de chat
  • La collaboration en temps réel (édition, jeux)
  • Les tableaux de bord et monitoring en direct
  • Les jeux multijoueurs

Pas idéal pour :

  • Les opérations CRUD simples (utiliser REST)
  • La récupération de données unique
  • L'accès aux ressources sans état

Le protocole WebSocket

Poignée de main (Handshake)

Les WebSockets commencent par HTTP, puis effectuent une mise à niveau (upgrade) vers le protocole WebSocket :

stateDiagram-v2
    [*] --> HTTP: Client envoie requête HTTP
    HTTP --> Handshake: Serveur reçoit
    Handshake --> WebSocket: 101 Switching Protocols
    WebSocket --> Connected: Full-duplex établi
    Connected --> Messaging: Envoyer/recevoir trames
    Messaging --> Closing: Trame de fermeture envoyée
    Closing --> [*]: Connexion terminée

Requête HTTP (mise à niveau) :

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Réponse HTTP (acceptation) :

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Structure des trames

Les messages WebSocket sont envoyés sous forme de trames, non de paquets HTTP :

+--------+--------+--------+--------+     +--------+
| FIN    | RSV1-3 | Opcode | Mask   | ... | Payload|
| 1 bit  | 3 bits | 4 bits | 1 bit  |     |        |
+--------+--------+--------+--------+     +--------+

Opcodes courants :
- 0x1: Trame de texte
- 0x2: Trame binaire
- 0x8: Fermer la connexion
- 0x9: Ping
- 0xA: Pong

Cycle de vie WebSocket

stateDiagram-v2
    [*] --> Connecting: ws://localhost:8080
    Connecting --> Open: Handshake terminé (101)
    Open --> Message: Envoyer/Recevoir des données
    Message --> Open: Continuer
    Open --> Closing: Fermeture normale ou erreur
    Closing --> Closed: Connexion TCP terminée
    Closed --> [*]

    note right of Connecting
        Le client envoie HTTP Upgrade
        Le serveur répond avec 101
    end note

    note right of Message
        Messagerie full-duplex
        Aucune surcharge par message
    end note

    note right of Closing
        Échange de trames de fermeture
        Arrêt gracieux
    end note

Implémentation : TypeScript

Nous utiliserons la bibliothèque ws - le standard de facto pour WebSockets dans Node.js.

Implémentation du serveur

// examples/03-chat/ts/ws-server.ts
import { WebSocketServer, WebSocket } from 'ws';

interface ChatMessage {
  type: 'message' | 'join' | 'leave';
  username: string;
  content: string;
  timestamp: number;
}

const wss = new WebSocketServer({ port: 8080 });

const clients = new Map<WebSocket, string>();

console.log('WebSocket server running on ws://localhost:8080');

wss.on('connection', (ws: WebSocket) => {
  console.log('New client connected');

  // Message de bienvenue
  ws.send(JSON.stringify({
    type: 'message',
    username: 'System',
    content: 'Welcome! Please identify yourself.',
    timestamp: Date.now()
  } as ChatMessage));

  // Gérer les messages entrants
  ws.on('message', (data: Buffer) => {
    try {
      const message: ChatMessage = JSON.parse(data.toString());

      if (message.type === 'join') {
        // Enregistrer le nom d'utilisateur
        clients.set(ws, message.username);
        console.log(`${message.username} joined`);

        // Diffuser à tous les clients
        broadcast({
          type: 'message',
          username: 'System',
          content: `${message.username} has joined the chat`,
          timestamp: Date.now()
        });
      } else if (message.type === 'message') {
        const username = clients.get(ws) || 'Anonymous';
        console.log(`${username}: ${message.content}`);

        // Diffuser le message
        broadcast({
          type: 'message',
          username,
          content: message.content,
          timestamp: Date.now()
        });
      }
    } catch (error) {
      console.error('Invalid message:', error);
    }
  });

  // Gérer la déconnexion
  ws.on('close', () => {
    const username = clients.get(ws);
    if (username) {
      console.log(`${username} disconnected`);
      clients.delete(ws);

      broadcast({
        type: 'message',
        username: 'System',
        content: `${username} has left the chat`,
        timestamp: Date.now()
      });
    }
  });

  // Gérer les erreurs
  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
  });
});

function broadcast(message: ChatMessage): void {
  const data = JSON.stringify(message);

  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}

// Heartbeat pour détecter les connexions obsolètes
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});

Implémentation du client

// examples/03-chat/ts/ws-client.ts
import { WebSocket } from 'ws';

interface ChatMessage {
  type: 'message' | 'join' | 'leave';
  username: string;
  content: string;
  timestamp: number;
}

class ChatClient {
  private ws: WebSocket;
  private username: string;
  private reconnectAttempts = 0;
  private readonly maxReconnectAttempts = 5;

  constructor(url: string, username: string) {
    this.username = username;
    this.ws = this.connect(url);
  }

  private connect(url: string): WebSocket {
    const ws = new WebSocket(url);

    ws.on('open', () => {
      console.log('Connected to chat server');
      this.reconnectAttempts = 0;

      // Nous identifier
      this.send({
        type: 'join',
        username: this.username,
        content: '',
        timestamp: Date.now()
      });
    });

    ws.on('message', (data: Buffer) => {
      const message: ChatMessage = JSON.parse(data.toString());
      this.displayMessage(message);
    });

    ws.on('close', () => {
      console.log('Disconnected from server');

      // Tenter la reconnexion
      if (this.reconnectAttempts < this.maxReconnectAttempts) {
        this.reconnectAttempts++;
        const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);

        console.log(`Reconnecting in ${delay}ms... (attempt ${this.reconnectAttempts})`);

        setTimeout(() => {
          this.ws = this.connect(url);
        }, delay);
      }
    });

    ws.on('error', (error) => {
      console.error('WebSocket error:', error.message);
    });

    // Répondre aux pings
    ws.on('ping', () => {
      ws.pong();
    });

    return ws;
  }

  public send(message: ChatMessage): void {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      console.error('Cannot send message: connection not open');
    }
  }

  public sendMessage(content: string): void {
    this.send({
      type: 'message',
      username: this.username,
      content,
      timestamp: Date.now()
    });
  }

  private displayMessage(message: ChatMessage): void {
    const time = new Date(message.timestamp).toLocaleTimeString();
    console.log(`[${time}] ${message.username}: ${message.content}`);
  }

  public close(): void {
    this.ws.close();
  }
}

// Interface CLI
const username = process.argv[2] || `User${Math.floor(Math.random() * 1000)}`;
const client = new ChatClient('ws://localhost:8080', username);

console.log(`You are logged in as: ${username}`);
console.log('Type a message and press Enter to send. Press Ctrl+C to exit.');

// Lire depuis stdin
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: Buffer) => {
  const text = chunk.toString().trim();
  if (text) {
    client.sendMessage(text);
  }
});

// Gérer l'arrêt gracieux
process.on('SIGINT', () => {
  console.log('\nShutting down...');
  client.close();
  process.exit(0);
});

Configuration du package

// examples/03-chat/ts/package.json
{
  "name": "chat-websocket-example",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "server": "node --loader ts-node/esm ws-server.ts",
    "client": "node --loader ts-node/esm ws-client.ts"
  },
  "dependencies": {
    "ws": "^8.18.0"
  },
  "devDependencies": {
    "@types/ws": "^8.5.12",
    "ts-node": "^10.9.2",
    "typescript": "^5.6.3"
  }
}

Implémentation : Python

Nous utiliserons la bibliothèque websockets - une implémentation WebSocket entièrement conforme.

Implémentation du serveur

# examples/03-chat/py/ws_server.py
import asyncio
import json
import logging
from datetime import datetime
from typing import Set
import websockets
from websockets.server import WebSocketServerProtocol

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Suivre les clients connectés
clients: Set[WebSocketServerProtocol] = set()
usernames: dict[WebSocketServerProtocol, str] = {}


async def broadcast(message: dict) -> None:
    """Envoyer un message à tous les clients connectés."""
    if clients:
        await asyncio.gather(
            *[client.send(json.dumps(message)) for client in clients if client.open],
            return_exceptions=True
        )


async def handle_client(websocket: WebSocketServerProtocol) -> None:
    """Gérer une connexion client."""
    clients.add(websocket)
    logger.info(f"New client connected. Total clients: {len(clients)}")

    try:
        # Envoyer un message de bienvenue
        welcome_msg = {
            "type": "message",
            "username": "System",
            "content": "Welcome! Please identify yourself.",
            "timestamp": datetime.now().timestamp()
        }
        await websocket.send(json.dumps(welcome_msg))

        # Gérer les messages
        async for message in websocket:
            try:
                data = json.loads(message)

                if data.get("type") == "join":
                    # Enregistrer le nom d'utilisateur
                    username = data.get("username", "Anonymous")
                    usernames[websocket] = username
                    logger.info(f"{username} joined")

                    # Diffuser la notification de rejoindre
                    await broadcast({
                        "type": "message",
                        "username": "System",
                        "content": f"{username} has joined the chat",
                        "timestamp": datetime.now().timestamp()
                    })

                elif data.get("type") == "message":
                    # Diffuser le message
                    username = usernames.get(websocket, "Anonymous")
                    content = data.get("content", "")
                    logger.info(f"{username}: {content}")

                    await broadcast({
                        "type": "message",
                        "username": username,
                        "content": content,
                        "timestamp": datetime.now().timestamp()
                    })

            except json.JSONDecodeError:
                logger.error("Invalid JSON received")
            except Exception as e:
                logger.error(f"Error handling message: {e}")

    except websockets.exceptions.ConnectionClosed:
        logger.info("Client disconnected unexpectedly")
    finally:
        # Nettoyage
        username = usernames.get(websocket)
        if username:
            del usernames[websocket]
            await broadcast({
                "type": "message",
                "username": "System",
                "content": f"{username} has left the chat",
                "timestamp": datetime.now().timestamp()
            })

        clients.discard(websocket)
        logger.info(f"Client removed. Total clients: {len(clients)}")


async def main():
    """Démarrer le serveur WebSocket."""
    async with websockets.serve(handle_client, "localhost", 8080):
        logger.info("WebSocket server running on ws://localhost:8080")
        await asyncio.Future()  # Run forever


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        logger.info("Server stopped")

Implémentation du client

# examples/03-chat/py/ws_client.py
import asyncio
import json
import sys
from datetime import datetime
import websockets
from websockets.client import WebSocketClientProtocol


class ChatClient:
    def __init__(self, url: str, username: str):
        self.url = url
        self.username = username
        self.websocket: WebSocketClientProtocol | None = None
        self.reconnect_attempts = 0
        self.max_reconnect_attempts = 5

    async def connect(self) -> None:
        """Connecter au serveur WebSocket."""
        backoff = 1

        while self.reconnect_attempts < self.max_reconnect_attempts:
            try:
                async with websockets.connect(self.url) as ws:
                    self.websocket = ws
                    self.reconnect_attempts = 0
                    print(f"Connected to {self.url}")

                    # Nous identifier
                    await self.send({
                        "type": "join",
                        "username": self.username,
                        "content": "",
                        "timestamp": datetime.now().timestamp()
                    })

                    # Commencer à recevoir des messages
                    receive_task = asyncio.create_task(self.receive_messages())

                    # Attendre la fermeture de la connexion
                    await ws.wait_closed()

                    # Annuler la tâche de réception
                    receive_task.cancel()
                    try:
                        await receive_task
                    except asyncio.CancelledError:
                        pass

                    print("Disconnected from server")

            except (ConnectionRefusedError, OSError) as e:
                self.reconnect_attempts += 1
                print(f"Connection failed: {e}")
                print(f"Reconnecting in {backoff}s... (attempt {self.reconnect_attempts})")

                await asyncio.sleep(backoff)
                backoff = min(backoff * 2, 30)

        print("Max reconnection attempts reached. Giving up.")

    async def receive_messages(self) -> None:
        """Recevoir et afficher les messages du serveur."""
        if not self.websocket:
            return

        try:
            async for message in self.websocket:
                data = json.loads(message)
                self.display_message(data)
        except asyncio.CancelledError:
            pass
        except Exception as e:
            print(f"Error receiving message: {e}")

    async def send(self, message: dict) -> None:
        """Envoyer un message au serveur."""
        if self.websocket and not self.websocket.closed:
            await self.websocket.send(json.dumps(message))
        else:
            print("Cannot send message: connection not open")

    def display_message(self, message: dict) -> None:
        """Afficher un message reçu."""
        timestamp = datetime.fromtimestamp(message["timestamp"]).strftime("%H:%M:%S")
        print(f"[{timestamp}] {message['username']}: {message['content']}")


async def stdin_reader(client: ChatClient):
    """Lire depuis stdin et envoyer des messages."""
    loop = asyncio.get_event_loop()

    while True:
        line = await loop.run_in_executor(None, sys.stdin.readline)
        text = line.strip()

        if text:
            await client.send({
                "type": "message",
                "username": client.username,
                "content": text,
                "timestamp": datetime.now().timestamp()
            })


async def main():
    """Exécuter le client de chat."""
    username = sys.argv[1] if len(sys.argv) > 1 else f"User{asyncio.get_event_loop().time() % 1000:.0f}"
    client = ChatClient("ws://localhost:8080", username)

    print(f"You are logged in as: {username}")
    print("Type a message and press Enter to send. Press Ctrl+C to exit.")

    # Exécuter la connexion et le lecteur stdin simultanément
    connect_task = asyncio.create_task(client.connect())

    # Donner du temps à la connexion pour s'établir
    await asyncio.sleep(0.5)

    stdin_task = asyncio.create_task(stdin_reader(client))

    try:
        await asyncio.gather(connect_task, stdin_task)
    except KeyboardInterrupt:
        print("\nShutting down...")
    finally:
        connect_task.cancel()
        stdin_task.cancel()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

Configuration requise

# examples/03-chat/py/requirements.txt
websockets==13.1

Configuration Docker Compose

Version TypeScript

# examples/03-chat/ts/docker-compose.yml
version: '3.8'

services:
  server:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - NODE_ENV=production
    restart: unless-stopped
# examples/03-chat/ts/Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --only=production

COPY . .
RUN npx tsc

EXPOSE 8080

CMD ["node", "dist/ws-server.js"]

Version Python

# examples/03-chat/py/docker-compose.yml
version: '3.8'

services:
  server:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    restart: unless-stopped
# examples/03-chat/py/Dockerfile
FROM python:3.12-alpine

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8080

CMD ["python", "ws_server.py"]

Exécution des exemples

TypeScript

# Installer les dépendances
cd examples/03-chat/ts
npm install

# Démarrer le serveur
npm run server

# Dans un autre terminal, démarrer un client
npm run client Alice

# Dans un autre terminal, démarrer un autre client
npm run client Bob

Python

# Installer les dépendances
cd examples/03-chat/py
pip install -r requirements.txt

# Démarrer le serveur
python ws_server.py

# Dans un autre terminal, démarrer un client
python ws_client.py Alice

# Dans un autre terminal, démarrer un autre client
python ws_client.py Bob

Avec Docker

# Démarrer le serveur
docker-compose up -d

# Vérifier les logs
docker-compose logs -f

# Se connecter avec un client (exécuter depuis l'hôte)
npm run client Alice  # ou python ws_client.py Alice

Bonnes pratiques de gestion des connexions

1. Heartbeat/Ping-Pong

Détecter les connexions obsolètes avant qu'elles ne causent des problèmes :

// Le serveur envoie un ping toutes les 30 secondes
setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

// Le client répond automatiquement
ws.on('ping', () => ws.pong());

2. Reconnexion avec backoff exponentiel

Ne pas surcharger le serveur lorsqu'il est en panne :

function reconnect(attempts: number) {
  const delay = Math.min(1000 * Math.pow(2, attempts), 30000);
  setTimeout(() => connect(), delay);
}

3. Arrêt gracieux

// Envoyer une trame de fermeture avant de terminer
ws.close(1000, 'Normal closure');

// Attendre l'accusé de réception de la trame de fermeture
ws.on('close', () => {
  console.log('Connection closed cleanly');
});

4. Sérialisation des messages

Toujours valider les messages entrants :

function safeParse(data: string): Message | null {
  try {
    const msg = JSON.parse(data);
    if (msg.type && msg.username) {
      return msg;
    }
  } catch {}
  return null;
}

Pièges courants

PiègeSymptômeSolution
Pas de gestion de la reconnexionLe client cesse de fonctionner sur une coupure réseauImplémenter la reconnexion avec backoff exponentiel
Ignorer l'événement closeFuites de mémoire des clients obsolètesToujours nettoyer à la déconnexion
Blocage de la boucle d'événementsMessages retardésUtiliser async/await correctement, éviter le travail CPU intensif
  • Heartbeat manquant | Les connexions obsolètes restent | Implémenter ping/pong |
  • Pas de validation des messages | Plantages sur des données malformées | Toujours essayer/attraper l'analyse JSON |

Test de votre implémentation WebSocket

# Utiliser websocat (comme curl pour WebSockets)
# Installation : cargo install websocat

# Connecter et envoyer/recevoir des messages
echo '{"type":"join","username":"TestUser","content":"","timestamp":123456}' | \
  websocat ws://localhost:8080

# Mode interactif
websocat ws://localhost:8080

Résumé

Les WebSockets permettent la communication en temps réel bidirectionnelle entre clients et serveurs :

  • Protocole : Poignée de main HTTP avec mise à niveau → connexion TCP persistante
  • Communication : Messagerie full-duplex avec une surcharge minimale
  • Cycle de vie : Connecting → Open → Messaging → Closing → Closed
  • Bonnes pratiques : Heartbeats, arrêt gracieux, gestion de la reconnexion

Dans la section suivante, nous développerons cette base pour implémenter la messagerie pub/sub pour les systèmes de chat multi-salles.

Exercices

Exercice 1 : Ajouter la messagerie privée

Étendre le système de chat pour prendre en charge les messages privés entre utilisateurs :

// Format de message pour les messages privés
{
  type: 'private',
  from: 'Alice',
  to: 'Bob',
  content: 'Hey Bob, are you there?',
  timestamp: 1234567890
}

Exigences :

  1. Ajouter un type de message private
  2. Acheminer les messages privés uniquement au destinataire prévu
  3. Afficher un indicateur de "message privé" dans l'interface

Exercice 2 : Indicateurs de frappe

Afficher quand un utilisateur est en train de taper :

// Message d'indicateur de frappe
{
  type: 'typing',
  username: 'Alice',
  isTyping: true,
  timestamp: 1234567890
}

Exigences :

  1. Envoyer typing.start lorsque l'utilisateur commence à taper
  2. Envoyer typing.stop après 2 secondes d'inactivité
  3. Afficher "Alice est en train de taper..." aux autres utilisateurs

Exercice 3 : État de connexion

Afficher l'état de connexion en temps réel à l'utilisateur :

Exigences :

  1. Afficher : Connecting → Connected → Disconnected → Reconnecting
  2. Utiliser des indicateurs visuels (point vert, point rouge, spinner)
  3. Afficher la latence ping/pong en millisecondes

Exercice 4 : Historique des messages avec reconnexion

Lorsqu'un client se reconnecte, lui envoyer les messages qu'il a manqués :

Exigences :

  1. Stocker les 100 derniers messages sur le serveur
  2. Lors de la reconnexion du client, envoyer les messages depuis son dernier horodatage
  3. Dédupliquer les messages que le client possède déjà

🧠 Quiz du chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Messagerie Pub/Sub et Ordonnancement des Messages

Session 7, Partie 1 - 45 minutes

Objectifs d'apprentissage

  • Comprendre le modèle de messagerie publish-subscribe
  • Apprendre le routage basé sur les sujets et le routage basé sur le contenu
  • Implémenter le suivi de présence et les abonnements
  • Comprendre les défis de l'ordonnancement des messages dans les systèmes distribués
  • Implémenter des numéros de séquence pour l'ordonnancement causal

Qu'est-ce que Pub/Sub ?

Le modèle publish-subscribe est un modèle de messagerie où les expéditeurs (publishers) envoient des messages à un système intermédiaire, et le système achemine les messages aux récepteurs intéressés (subscribers). Les publishers et subscribers sont découplés - ils ne se connaissent pas.

Avantages clés

  1. Découplage : Les publishers n'ont pas besoin de savoir qui s'abonne
  2. Extensibilité : Ajouter des subscribers sans modifier les publishers
  3. Flexibilité : Gestion dynamique des abonnements
  4. Asynchronie : Les publishers envoient et continuent ; les subscribers traitent quand ils sont prêts

Pub/Sub vs Messagerie directe

graph TB
    subgraph "Messagerie directe"
        P1[Producer] -->|Direct| C1[Consumer 1]
        P1 -->|Direct| C2[Consumer 2]
        P1 -->|Direct| C3[Consumer 3]
    end

    subgraph "Messagerie Pub/Sub"
        P2[Publisher] -->|Publish| B[Broker]
        S1[Subscriber 1] -->|Subscribe| B
        S2[Subscriber 2] -->|Subscribe| B
        S3[Subscriber 3] -->|Subscribe| B
    end
AspectMessagerie directePub/Sub
CouplageFort (le producteur connaît les consumers)Faible (le producteur ne connaît pas les consumers)
FlexibilitéFaible (les changements affectent le producteur)Élevée (abonnements dynamiques)
ComplexitéSimpleModérée (nécessite un broker)
Cas d'usagePoint-à-point, requête-réponseDiffusion, événements, notifications

Modèles Pub/Sub

1. Routage basé sur les sujets

Les subscribers expriment leur intérêt pour des sujets (channels). Les messages sont acheminés en fonction du sujet auquel ils sont publiés.

sequenceDiagram
    participant S1 as Subscriber 1
    participant S2 as Subscriber 2
    participant S3 as Subscriber 3
    participant B as Broker
    participant P as Publisher

    Note over S1,S3: Phase d'abonnement
    S1->>B: subscribe("sports")
    S2->>B: subscribe("sports")
    S3->>B: subscribe("news")

    Note over S1,S3: Phase de publication
    P->>B: publish("sports", "Game starts!")
    B->>S1: deliver("Game starts!")
    B->>S2: deliver("Game starts!")

    P->>B: publish("news", "Breaking story!")
    B->>S3: deliver("Breaking story!")

Cas d'usage : Salles de chat, catégories de notifications, flux d'événements

2. Routage basé sur le contenu

Les subscribers spécifient des critères de filtrage. Les messages sont acheminés en fonction de leur contenu.

graph LR
    P[Publisher] -->|{"type": "order", "value": >100}| B[Content Router]
    B -->|Matches filter| S1[High-Value Handler]
    B -->|Matches filter| S2[Order Logger]
    B -.->|No match| S3[Low-Value Handler]

Cas d'usage : Filtrage d'événements, règles de routage complexes, données de capteurs IoT

3. Suivi de présence

Dans les systèmes en temps réel, savoir qui est en ligne (presence) est essentiel pour :

  • Afficher le statut en ligne/hors ligne
  • Livrer les messages uniquement aux utilisateurs actifs
  • Gérer les connexions et reconnexions
  • Gérer gracieusement les déconnexions utilisateurs
stateDiagram-v2
    [*] --> Offline: Utilisateur créé
    Offline --> Connecting: Demande de connexion
    Connecting --> Online: Auth réussie
    Connecting --> Offline: Auth échouée
    Online --> Away: Pas d'activité
    Online --> Offline: Déconnexion
    Away --> Online: Activité détectée
    Online --> [*]: Utilisateur supprimé

Ordonnancement des messages

Le problème de l'ordonnancement

Dans les systèmes distribués, les messages peuvent arriver dans le désordre en raison de :

  • Variations de latence réseau
  • Serveurs multiples traitant des messages
  • Nouvelles tentatives et retransmissions de messages
  • Publishers simultanés

Types d'ordonnancement

Type d'ordonnancementDescriptionDifficulté
FIFOLes messages du même expéditeur arrivent dans l'ordre d'envoiFacile
CausalLes messages causalement liés sont ordonnésModérée
TotalTous les messages sont ordonnés globalementDifficile

Pourquoi l'ordonnancement est important

Considérons une application de chat :

sequenceDiagram
    participant A as Alice
    participant S as Server
    participant B as Bob

    Note over A,B: Sans ordonnancement - confusion !
    A->>S: "Let's meet at 5pm"
    A->>S: "Never mind, 6pm instead"
    S--xB: "Never mind, 6pm instead"
    S--xB: "Let's meet at 5pm"

    Note over B: Bob voit les messages dans le désordre !

Avec un ordonnancement approprié utilisant des numéros de séquence :

sequenceDiagram
    participant A as Alice
    participant S as Server
    participant B as Bob

    Note over A,B: Avec numéros de séquence - correct !
    A->>S: [msg#1] "Let's meet at 5pm"
    A->>S: [msg#2] "Never mind, 6pm instead"

    S--xB: [msg#1] "Let's meet at 5pm"
    S--xB: [msg#2] "Never mind, 6pm instead"

    Note over B: Bob livre dans l'ordre par numéro de séquence

Implémentation : Chat Pub/Sub avec ordonnancement

Construisons un système de chat pub/sub avec :

  • Routage basé sur les sujets (salles de chat)
  • Suivi de présence
  • Ordonnancement des messages avec numéros de séquence

Implémentation TypeScript

pubsub-server.ts - Serveur Pub/Sub avec ordonnancement :

// src: examples/03-chat/ts/pubsub-server.ts

interface Message {
  id: string;
  room: string;
  sender: string;
  content: string;
  sequence: number;
  timestamp: number;
}

interface Subscriber {
  id: string;
  userId: string;
  rooms: Set<string>;
  ws: WebSocket;
}

class PubSubServer {
  private subscribers: Map<string, Subscriber> = new Map();
  private roomSequences: Map<string, number> = new Map();
  private messageHistory: Map<string, Message[]> = new Map();
  private server: WebSocketServer;

  constructor(port: number = 8080) {
    this.server = new WebSocketServer({ port });
    this.setupHandlers();
    console.log(`Pub/Sub server running on port ${port}`);
  }

  private setupHandlers() {
    this.server.on('connection', (ws: WebSocket) => {
      const subscriberId = this.generateId();

      ws.on('message', (data: string) => {
        try {
          const msg = JSON.parse(data.toString());
          this.handleMessage(subscriberId, msg, ws);
        } catch (err) {
          ws.send(JSON.stringify({ error: 'Invalid message format' }));
        }
      });

      ws.on('close', () => {
        this.handleDisconnect(subscriberId);
      });
    });
  }

  private handleMessage(subscriberId: string, msg: any, ws: WebSocket) {
    switch (msg.type) {
      case 'subscribe':
        this.handleSubscribe(subscriberId, msg.room, msg.userId, ws);
        break;
      case 'unsubscribe':
        this.handleUnsubscribe(subscriberId, msg.room);
        break;
      case 'publish':
        this.handlePublish(msg);
        break;
      case 'get_history':
        this.handleGetHistory(msg.room, ws);
        break;
    }
  }

  private handleSubscribe(
    subscriberId: string,
    room: string,
    userId: string,
    ws: WebSocket
  ) {
    if (!this.subscribers.has(subscriberId)) {
      this.subscribers.set(subscriberId, {
        id: subscriberId,
        userId,
        rooms: new Set(),
        ws,
      });
    }

    const subscriber = this.subscribers.get(subscriberId)!;
    subscriber.rooms.add(room);

    // Initialiser l'état de la salle si nécessaire
    if (!this.roomSequences.has(room)) {
      this.roomSequences.set(room, 0);
      this.messageHistory.set(room, []);
    }

    // Envoyer une notification de présence
    this.broadcast(room, {
      type: 'presence',
      userId,
      action: 'join',
      timestamp: Date.now(),
    });

    // Envoyer le numéro de séquence actuel
    ws.send(JSON.stringify({
      type: 'subscribed',
      room,
      sequence: this.roomSequences.get(room),
    }));

    console.log(`${userId} subscribed to ${room}`);
  }

  private handleUnsubscribe(subscriberId: string, room: string) {
    const subscriber = this.subscribers.get(subscriberId);
    if (subscriber) {
      subscriber.rooms.delete(room);

      // Envoyer une notification de présence
      this.broadcast(room, {
        type: 'presence',
        userId: subscriber.userId,
        action: 'leave',
        timestamp: Date.now(),
      });
    }
  }

  private handlePublish(msg: any) {
    const { room, sender, content } = msg;
    const sequence = (this.roomSequences.get(room) || 0) + 1;
    this.roomSequences.set(room, sequence);

    const message: Message = {
      id: this.generateId(),
      room,
      sender,
      content,
      sequence,
      timestamp: Date.now(),
    };

    // Stocker dans l'historique
    const history = this.messageHistory.get(room) || [];
    history.push(message);
    this.messageHistory.set(room, history.slice(-100)); // Garder les 100 derniers

    // Diffuser à tous les subscribers
    this.broadcast(room, {
      type: 'message',
      ...message,
    });
  }

  private handleGetHistory(room: string, ws: WebSocket) {
    const history = this.messageHistory.get(room) || [];
    ws.send(JSON.stringify({
      type: 'history',
      room,
      messages: history,
    }));
  }

  private broadcast(room: string, payload: any) {
    const payloadStr = JSON.stringify(payload);

    for (const [_, subscriber] of this.subscribers) {
      if (subscriber.rooms.has(room) && subscriber.ws.readyState === WebSocket.OPEN) {
        subscriber.ws.send(payloadStr);
      }
    }
  }

  private handleDisconnect(subscriberId: string) {
    const subscriber = this.subscribers.get(subscriberId);
    if (subscriber) {
      // Notifier toutes les salles où l'utilisateur était
      for (const room of subscriber.rooms) {
        this.broadcast(room, {
          type: 'presence',
          userId: subscriber.userId,
          action: 'leave',
          timestamp: Date.now(),
        });
      }
      this.subscribers.delete(subscriberId);
    }
  }

  private generateId(): string {
    return Math.random().toString(36).substring(2, 15);
  }
}

const PORT = parseInt(process.env.PORT || '8080');
new PubSubServer(PORT);

pubsub-client.ts - Client avec tampon d'ordonnancement :

// src: examples/03-chat/ts/pubsub-client.ts

interface ClientMessage {
  type: string;
  sequence?: number;
  [key: string]: any;
}

class PubSubClient {
  private ws: WebSocket | null = null;
  private userId: string;
  private messageBuffer: Map<string, Map<number, ClientMessage>> = new Map();
  private expectedSequence: Map<string, number> = new Map();
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;

  constructor(
    private url: string,
    userId?: string
  ) {
    this.userId = userId || `user-${Math.random().toString(36).substring(7)}`;
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.on('open', () => {
      console.log(`Connected as ${this.userId}`);
      this.reconnectAttempts = 0;
    });

    this.ws.on('message', (data: string) => {
      const msg: ClientMessage = JSON.parse(data.toString());
      this.handleMessage(msg);
    });

    this.ws.on('close', () => {
      console.log('Disconnected. Attempting to reconnect...');
      this.reconnect();
    });

    this.ws.on('error', (err) => {
      console.error('WebSocket error:', err);
    });
  }

  private handleMessage(msg: ClientMessage) {
    switch (msg.type) {
      case 'subscribed':
        this.expectedSequence.set(msg.room, (msg.sequence || 0) + 1);
        console.log(`Subscribed to ${msg.room} at sequence ${msg.sequence}`);
        break;

      case 'message':
        this.handleOrderedMessage(msg.room, msg);
        break;

      case 'presence':
        console.log(`${msg.userId} ${msg.action}ed`);
        break;

      case 'history':
        console.log(`Received ${msg.messages.length} historical messages`);
        msg.messages.forEach((m: ClientMessage) => this.displayMessage(m));
        break;
    }
  }

  private handleOrderedMessage(room: string, msg: ClientMessage) {
    const seq = msg.sequence!;

    // Initialiser le tampon si nécessaire
    if (!this.messageBuffer.has(room)) {
      this.messageBuffer.set(room, new Map());
    }
    const buffer = this.messageBuffer.get(room)!;
    const expected = this.expectedSequence.get(room) || 1;

    if (seq === expected) {
      // Message attendu - livrer immédiatement
      this.displayMessage(msg);
      this.expectedSequence.set(room, seq + 1);

      // Vérifier le tampon pour les messages suivants
      this.deliverBufferedMessages(room);
    } else if (seq > expected) {
      // Message futur - le mettre en tampon
      buffer.set(seq, msg);
      console.log(`Buffered message ${seq} (expecting ${expected})`);
    }
    // seq < expected: ancien message, ignorer
  }

  private deliverBufferedMessages(room: string) {
    const buffer = this.messageBuffer.get(room);
    if (!buffer) return;

    const expected = this.expectedSequence.get(room) || 1;

    while (buffer.has(expected)) {
      const msg = buffer.get(expected)!;
      this.displayMessage(msg);
      buffer.delete(expected);
      this.expectedSequence.set(room, expected + 1);
    }
  }

  private displayMessage(msg: ClientMessage) {
    console.log(`[${msg.sequence}] ${msg.sender}: ${msg.content}`);
  }

  subscribe(room: string) {
    this.send({ type: 'subscribe', room, userId: this.userId });
  }

  unsubscribe(room: string) {
    this.send({ type: 'unsubscribe', room });
  }

  publish(room: string, content: string) {
    this.send({
      type: 'publish',
      room,
      sender: this.userId,
      content,
    });
  }

  getHistory(room: string) {
    this.send({ type: 'get_history', room });
  }

  private send(payload: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(payload));
    } else {
      console.error('WebSocket not connected');
    }
  }

  private reconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
      setTimeout(() => this.connect(), delay);
    } else {
      console.error('Max reconnection attempts reached');
    }
  }
}

// Usage en CLI
const args = process.argv.slice(2);
const url = args[0] || 'ws://localhost:8080';
const client = new PubSubClient(url);

client.connect();

// Interface readline simple
const readline = require('readline');
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

console.log('Commands: /join <room>, /leave <room>, /history <room>, /quit');
console.log('Any other input will be sent to the current room');

let currentRoom = '';

const showPrompt = () => {
  if (currentRoom) {
    rl.question(`[${currentRoom}]> `, (input) => {
      if (input === '/quit') {
        client.ws?.close();
        rl.close();
        process.exit(0);
      } else if (input.startsWith('/join ')) {
        currentRoom = input.substring(6);
        client.subscribe(currentRoom);
      } else if (input.startsWith('/leave ')) {
        const room = input.substring(7);
        client.unsubscribe(room);
        if (room === currentRoom) currentRoom = '';
      } else if (input.startsWith('/history ')) {
        const room = input.substring(9);
        client.getHistory(room);
      } else if (input && currentRoom) {
        client.publish(currentRoom, input);
      }
      showPrompt();
    });
  } else {
    rl.question('(no room)> ', (input) => {
      if (input.startsWith('/join ')) {
        currentRoom = input.substring(6);
        client.subscribe(currentRoom);
      }
      showPrompt();
    });
  }
};

showPrompt();

Implémentation Python

pubsub_server.py - Serveur Pub/Sub avec ordonnancement :

# src: examples/03-chat/py/pubsub_server.py

import asyncio
import json
import time
from typing import Dict, Set, List
from dataclasses import dataclass, asdict
import websockets
from websockets.server import WebSocketServerProtocol

@dataclass
class Message:
    id: str
    room: str
    sender: str
    content: str
    sequence: int
    timestamp: int

class PubSubServer:
    def __init__(self, port: int = 8080):
        self.port = port
        self.subscribers: Dict[str, dict] = {}
        self.room_sequences: Dict[str, int] = {}
        self.message_history: Dict[str, List[Message]] = {}

    async def handle_connection(self, ws: WebSocketServerProtocol):
        subscriber_id = self._generate_id()

        try:
            async for message in ws:
                try:
                    data = json.loads(message)
                    await self.handle_message(subscriber_id, data, ws)
                except json.JSONDecodeError:
                    await ws.send(json.dumps({"error": "Invalid message format"}))
        finally:
            await self.handle_disconnect(subscriber_id)

    async def handle_message(self, subscriber_id: str, msg: dict, ws: WebSocketServerProtocol):
        msg_type = msg.get("type")

        if msg_type == "subscribe":
            await self.handle_subscribe(subscriber_id, msg["room"], msg["userId"], ws)
        elif msg_type == "unsubscribe":
            await self.handle_unsubscribe(subscriber_id, msg["room"])
        elif msg_type == "publish":
            await self.handle_publish(msg)
        elif msg_type == "get_history":
            await self.handle_get_history(msg["room"], ws)

    async def handle_subscribe(
        self, subscriber_id: str, room: str, user_id: str, ws: WebSocketServerProtocol
    ):
        if subscriber_id not in self.subscribers:
            self.subscribers[subscriber_id] = {
                "id": subscriber_id,
                "userId": user_id,
                "rooms": set(),
                "ws": ws,
            }

        subscriber = self.subscribers[subscriber_id]
        subscriber["rooms"].add(room)

        # Initialiser l'état de la salle
        if room not in self.room_sequences:
            self.room_sequences[room] = 0
            self.message_history[room] = []

        # Envoyer une notification de présence
        await self.broadcast(room, {
            "type": "presence",
            "userId": user_id,
            "action": "join",
            "timestamp": int(time.time() * 1000),
        })

        # Envoyer le numéro de séquence actuel
        await ws.send(json.dumps({
            "type": "subscribed",
            "room": room,
            "sequence": self.room_sequences[room],
        }))

        print(f"{user_id} subscribed to {room}")

    async def handle_unsubscribe(self, subscriber_id: str, room: str):
        subscriber = self.subscribers.get(subscriber_id)
        if subscriber:
            subscriber["rooms"].discard(room)

            await self.broadcast(room, {
                "type": "presence",
                "userId": subscriber["userId"],
                "action": "leave",
                "timestamp": int(time.time() * 1000),
            })

    async def handle_publish(self, msg: dict):
        room = msg["room"]
        sender = msg["sender"]
        content = msg["content"]

        sequence = self.room_sequences.get(room, 0) + 1
        self.room_sequences[room] = sequence

        message = Message(
            id=self._generate_id(),
            room=room,
            sender=sender,
            content=content,
            sequence=sequence,
            timestamp=int(time.time() * 1000),
        )

        # Stocker dans l'historique
        history = self.message_history[room]
        history.append(message)
        self.message_history[room] = history[-100:]  # Garder les 100 derniers

        # Diffuser
        await self.broadcast(room, {
            "type": "message",
            **asdict(message),
        })

    async def handle_get_history(self, room: str, ws: WebSocketServerProtocol):
        history = self.message_history.get(room, [])
        await ws.send(json.dumps({
            "type": "history",
            "room": room,
            "messages": [asdict(m) for m in history],
        }))

    async def broadcast(self, room: str, payload: dict):
        payload_str = json.dumps(payload)
        tasks = []

        for subscriber in self.subscribers.values():
            if room in subscriber["rooms"]:
                ws = subscriber["ws"]
                if not ws.closed:
                    tasks.append(ws.send(payload_str))

        if tasks:
            await asyncio.gather(*tasks, return_exceptions=True)

    async def handle_disconnect(self, subscriber_id: str):
        subscriber = self.subscribers.get(subscriber_id)
        if subscriber:
            # Notifier toutes les salles
            for room in list(subscriber["rooms"]):
                await self.broadcast(room, {
                    "type": "presence",
                    "userId": subscriber["userId"],
                    "action": "leave",
                    "timestamp": int(time.time() * 1000),
                })

            del self.subscribers[subscriber_id]

    def _generate_id(self) -> str:
        import random
        import string
        return ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))

    async def start(self):
        print(f"Pub/Sub server running on port {self.port}")
        async with websockets.serve(self.handle_connection, "", self.port):
            await asyncio.Future()  # Run forever

if __name__ == "__main__":
    import os
    port = int(os.environ.get("PORT", "8080"))
    server = PubSubServer(port)
    asyncio.run(server.start())

pubsub_client.py - Client avec tampon d'ordonnancement :

# src: examples/03-chat/py/pubsub_client.py

import asyncio
import json
import time
from typing import Dict, Optional
import websockets
from websockets.client import WebSocketClientProtocol

class PubSubClient:
    def __init__(self, url: str, user_id: Optional[str] = None):
        self.url = url
        self.user_id = user_id or f"user-{int(time.time())}"
        self.ws: Optional[WebSocketClientProtocol] = None
        self.message_buffer: Dict[str, Dict[int, dict]] = {}
        self.expected_sequence: Dict[str, int] = {}
        self.reconnect_attempts = 0
        self.max_reconnect_attempts = 5

    async def connect(self):
        try:
            self.ws = await websockets.connect(self.url)
            print(f"Connected as {self.user_id}")
            self.reconnect_attempts = 0
            asyncio.create_task(self.listen())
        except Exception as e:
            print(f"Connection failed: {e}")
            await self.reconnect()

    async def listen(self):
        if not self.ws:
            return

        try:
            async for message in self.ws:
                data = json.loads(message)
                await self.handle_message(data)
        except websockets.exceptions.ConnectionClosed:
            print("Disconnected. Attempting to reconnect...")
            await self.reconnect()

    async def handle_message(self, msg: dict):
        msg_type = msg.get("type")

        if msg_type == "subscribed":
            room = msg["room"]
            self.expected_sequence[room] = msg.get("sequence", 0) + 1
            print(f"Subscribed to {room} at sequence {msg.get('sequence', 0)}")

        elif msg_type == "message":
            await self.handle_ordered_message(msg["room"], msg)

        elif msg_type == "presence":
            print(f"{msg['userId']} {msg['action']}ed")

        elif msg_type == "history":
            print(f"Received {len(msg['messages'])} historical messages")
            for m in msg["messages"]:
                self.display_message(m)

    async def handle_ordered_message(self, room: str, msg: dict):
        seq = msg["sequence"]

        if room not in self.message_buffer:
            self.message_buffer[room] = {}

        buffer = self.message_buffer[room]
        expected = self.expected_sequence.get(room, 1)

        if seq == expected:
            # Message attendu - livrer immédiatement
            self.display_message(msg)
            self.expected_sequence[room] = seq + 1

            # Vérifier le tampon pour les messages suivants
            await self.deliver_buffered_messages(room)

        elif seq > expected:
            # Message futur - le mettre en tampon
            buffer[seq] = msg
            print(f"Buffered message {seq} (expecting {expected})")

    async def deliver_buffered_messages(self, room: str):
        buffer = self.message_buffer.get(room, {})
        expected = self.expected_sequence.get(room, 1)

        while expected in buffer:
            msg = buffer[expected]
            self.display_message(msg)
            del buffer[expected]
            self.expected_sequence[room] = expected + 1
            expected += 1

    def display_message(self, msg: dict):
        print(f"[{msg['sequence']}] {msg['sender']}: {msg['content']}")

    async def subscribe(self, room: str):
        await self.send({"type": "subscribe", "room": room, "userId": self.user_id})

    async def unsubscribe(self, room: str):
        await self.send({"type": "unsubscribe", "room": room})

    async def publish(self, room: str, content: str):
        await self.send({
            "type": "publish",
            "room": room,
            "sender": self.user_id,
            "content": content,
        })

    async def get_history(self, room: str):
        await self.send({"type": "get_history", "room": room})

    async def send(self, payload: dict):
        if self.ws and not self.ws.closed:
            await self.ws.send(json.dumps(payload))
        else:
            print("WebSocket not connected")

    async def reconnect(self):
        if self.reconnect_attempts < self.max_reconnect_attempts:
            self.reconnect_attempts += 1
            delay = min(1000 * (2 ** self.reconnect_attempts), 30000) / 1000
            await asyncio.sleep(delay)
            await self.connect()
        else:
            print("Max reconnection attempts reached")

async def main():
    import sys
    url = sys.argv[1] if len(sys.argv) > 1 else "ws://localhost:8080"
    client = PubSubClient(url)
    await client.connect()

    # CLI simple
    current_room = ""

    print('Commands: /join <room>, /leave <room>, /history <room>, /quit')

    while True:
        try:
            prompt = f"[{current_room}]> " if current_room else "(no room)> "
            line = await asyncio.get_event_loop().run_in_executor(None, input, prompt)

            if line == "/quit":
                break
            elif line.startswith("/join "):
                current_room = line[6:]
                await client.subscribe(current_room)
            elif line.startswith("/leave "):
                room = line[7:]
                await client.unsubscribe(room)
                if room == current_room:
                    current_room = ""
            elif line.startswith("/history "):
                room = line[9:]
                await client.get_history(room)
            elif line and current_room:
                await client.publish(current_room, line)

        except EOFError:
            break

    if client.ws:
        await client.ws.close()

if __name__ == "__main__":
    asyncio.run(main())

Exécution des exemples

Version TypeScript

cd distributed-systems-course/examples/03-chat/ts

# Installer les dépendances
npm install

# Démarrer le serveur
PORT=8080 npx ts-node pubsub-server.ts

# Dans un autre terminal, démarrer un client
npx ts-node pubsub-client.ts

Version Python

cd distributed-systems-course/examples/03-chat/py

# Installer les dépendances
pip install -r requirements.txt

# Démarrer le serveur
PORT=8080 python pubsub_server.py

# Dans un autre terminal, démarrer un client
python pubsub_client.py

Docker Compose

docker-compose.yml (TypeScript) :

services:
  pubsub-server:
    build: .
    ports:
      - "8080:8080"
    environment:
      - PORT=8080
docker-compose up

Test du système Pub/Sub

Test 1 : Pub/Sub de base

  1. Démarrer trois clients dans des terminaux séparés
  2. Client 1 : /join general
  3. Client 2 : /join general
  4. Client 1 : Hello everyone!
  5. Le client 2 devrait recevoir le message
  6. Client 3 : /join general
  7. Client 3 : /history general - devrait voir les messages précédents

Test 2 : Salles multiples

  1. Client 1 : /join sports
  2. Client 2 : /join news
  3. Client 1 : Game starting! (uniquement dans sports)
  4. Client 2 : Breaking news! (uniquement dans news)
  5. Client 3 : /join sports et /join news (reçoit les deux)

Test 3 : Ordonnancement des messages

  1. Démarrer un client et rejoindre une salle
  2. Envoyer des messages rapidement : msg1, msg2, msg3
  3. Observer les numéros de séquence : [1], [2], [3]
  4. Noter que l'ordre est préservé

Test 4 : Suivi de présence

  1. Démarrer deux clients
  2. Les deux rejoignent la même salle
  3. Observer les notifications de présence (utilisateur rejoint/parti)
  4. Déconnecter un client (Ctrl+C)
  5. L'autre client reçoit la notification de départ

Exercices

Exercice 1 : Implémenter le cache des derniers messages

Ajouter une fonctionnalité pour stocker uniquement les derniers N messages par salle (déjà implémenté comme 100 dans le code).

Tâches :

  • Rendre la taille de l'historique configurable via une variable d'environnement
  • Ajouter une commande /clear_history pour les administrateurs
  • Ajouter un TTL (time-to-live) pour les anciens messages

Exercice 2 : Implémenter les messages privés

Étendre le système pour prendre en charge les messages directs entre utilisateurs.

Exigences :

  • Les messages privés ne doivent être livrés qu'au destinataire
  • Utiliser un format de sujet spécial : @username
  • Inclure l'authentification de l'expéditeur

Indice : Vous devrez modifier la méthode handlePublish pour vérifier le préfixe @.

Exercice 3 : Ajouter les accusés de réception de messages

Implémenter des accusés de réception pour garantir la livraison des messages.

Exigences :

  • Les clients doivent ACK les messages reçus
  • Le serveur suit les messages non accusés
  • À la reconnexion, le serveur renvoie les messages non accusés

Indice : Ajoutez un type de message ack et suivez les messages en attente par subscriber.

Pièges courants

PiègeSymptômeSolution
Désynchronisation des numéros de séquenceMessages non affichésSe réabonner pour réinitialiser la séquence
Fuite de mémoire de l'historiqueUtilisation mémoire croissanteImplémenter des limites de taille d'historique
Mises à jour de présence manquantesStatut en ligne obsolèteAjouter des messages heartbeat/ping
Conditions de courseMessages perdus lors de la reconnexionMettre en tampon les messages pendant la déconnexion

Exemples réels

SystèmeImplémentation Pub/SubStratégie d'ordonnancement
Redis Pub/SubCanaux basés sur des sujetsAucune garantie d'ordonnancement
Apache KafkaSujets partitionnésOrdonnancement par partition
Google Cloud Pub/SubSujets avec abonnementsLivraison exactement une fois
AWS SNSDiffusion basée sur des sujetsOrdonnancement au mieux (best-effort)
RabbitMQLiaison exchange/queueFIFO dans la file

Résumé

  • Pub/Sub découple les publishers des subscribers via un broker intermédiaire
  • Le routage basé sur les sujets est le modèle le plus simple et le plus courant
  • Le suivi de présence permet le statut en ligne/hors ligne dans les systèmes temps réel
  • L'ordonnancement des messages nécessite des numéros de séquence et la mise en tampon
  • L'ordonnancement causal est réalisable avec une complexité modeste
  • L'ordonnancement total est coûteux et souvent inutile

Suivant : Implémentation du système de chat →

🧠 Quiz du chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Implémentation du Système de Chat

Session 7 - Session complète (90 minutes)

Objectifs d'apprentissage

  • Construire un système de chat en temps réel complet avec WebSockets
  • Implémenter l'ordonnancement des messages avec numéros de séquence
  • Gérer la gestion de présence (utilisateurs en ligne/hors ligne)
  • Ajouter la persistance des messages pour l'historique
  • Déployer plusieurs salles de chat avec Docker Compose

Architecture du système

Notre système de chat rassemble tous les concepts des sessions 6-7 :

graph TB
    subgraph "Clients"
        C1[Navigateur Utilisateur 1]
        C2[Navigateur Utilisateur 2]
        C3[Navigateur Utilisateur 3]
    end

    subgraph "Serveur de Chat"
        WS[Gestionnaire WebSocket]
        PS[Moteur Pub/Sub]
        SM[Gestionnaire de Séquence]
        PM[Gestionnaire de Présence]
        MS[Stockage de Messages]

        WS --> PS
        WS --> SM
        WS --> PM
        PS --> SM
        SM --> MS
    end

    C1 -->|WebSocket| WS
    C2 -->|WebSocket| WS
    C3 -->|WebSocket| WS

    subgraph "Persistance"
        DB[(Base de Messages)]
    end

    MS --> DB

    style WS fill:#e3f2fd
    style PS fill:#fff3e0
    style SM fill:#f3e5f5

Composants clés

ComposantResponsabilité
Gestionnaire WebSocketGère les connexions client, envoie/reçoit les messages
Moteur Pub/SubAchemine les messages vers les salles, gère les abonnements
Gestionnaire de SéquenceAttribue des numéros de séquence, assure l'ordonnancement
Gestionnaire de PrésenceSuit le statut en ligne/hors ligne, heartbeat
Stockage de MessagesPersiste les messages pour l'historique et la relecture

Flux des messages

sequenceDiagram
    participant U1 as Utilisateur 1
    participant WS as Gestionnaire WebSocket
    participant PS as Pub/Sub
    participant SM as Séquenceur
    participant DB as Stockage de Messages
    participant U2 as Utilisateur 2

    U1->>WS: CONNECT("general")
    WS->>PS: subscribe("general", U1)
    WS->>PM: mark_online(U1)
    PS->>U2: BROADCAST("Utilisateur 1 a rejoint")

    Note over U1,U2: Envoi d'un message
    U1->>WS: SEND("general", "Bonjour!")
    WS->>PS: publish("general", msg)
    PS->>SM: get_sequence(msg)
    SM->>DB: save(msg, seq=1)
    SM->>PS: return seq=1
    PS->>U1: BROADCAST(msg, seq=1)
    PS->>U2: BROADCAST(msg, seq=1)

    Note over U1,U2: Utilisateur 2 se reconnecte
    U2->>WS: CONNECT("general", last_seq=0)
    WS->>DB: get_messages(since=0)
    DB->>U2: REPLAY([msg1, msg2, ...])

Implémentation TypeScript

Structure du projet

chat-system/
├── package.json
├── tsconfig.json
├── src/
│   ├── types.ts          # Définitions de type
│   ├── pub-sub.ts        # Moteur Pub/Sub
│   ├── sequencer.ts      # Gestionnaire de numéros de séquence
│   ├── presence.ts       # Gestion de présence
│   ├── store.ts          # Persistance des messages
│   ├── server.ts         # Serveur WebSocket
│   └── index.ts          # Point d'entrée
├── public/
│   └── client.html       # Client de démo
├── Dockerfile
└── docker-compose.yml

1. Définitions de type

// src/types.ts
export interface Message {
    id: string;
    room: string;
    user: string;
    content: string;
    sequence: number;
    timestamp: number;
}

export interface Client {
    id: string;
    user: string;
    rooms: Set<string>;
    ws: WebSocket;
    lastSeen: number;
}

export interface Presence {
    user: string;
    status: 'online' | 'offline' | 'away';
    lastSeen: number;
}

export type MessageHandler = (client: Client, message: Message) => void;

2. Moteur Pub/Sub

// src/pub-sub.ts
import { Message, Client, MessageHandler } from './types';

export class PubSub {
    private subscriptions: Map<string, Set<Client>> = new Map();
    private handlers: Map<string, MessageHandler[]> = new Map();

    subscribe(room: string, client: Client): void {
        if (!this.subscriptions.has(room)) {
            this.subscriptions.set(room, new Set());
        }
        this.subscriptions.get(room)!.add(client);
        client.rooms.add(room);
    }

    unsubscribe(room: string, client: Client): void {
        const subs = this.subscriptions.get(room);
        if (subs) {
            subs.delete(client);
            if (subs.size === 0) {
                this.subscriptions.delete(room);
            }
        }
        client.rooms.delete(room);
    }

    publish(room: string, message: Message): void {
        const subs = this.subscriptions.get(room);
        if (subs) {
            for (const client of subs) {
                this.sendToClient(client, message);
            }
        }
        this.emit('message', message);
    }

    on(event: string, handler: MessageHandler): void {
        if (!this.handlers.has(event)) {
            this.handlers.set(event, []);
        }
        this.handlers.get(event)!.push(handler);
    }

    private emit(event: string, message: Message): void {
        const handlers = this.handlers.get(event) || [];
        handlers.forEach(h => h(null!, message));
    }

    private sendToClient(client: Client, message: Message): void {
        if (client.ws.readyState === client.ws.OPEN) {
            client.ws.send(JSON.stringify({
                type: 'message',
                data: message
            }));
        }
    }

    getSubscribers(room: string): Client[] {
        return Array.from(this.subscriptions.get(room) || []);
    }

    getRooms(): string[] {
        return Array.from(this.subscriptions.keys());
    }
}

3. Gestionnaire de séquence

// src/sequencer.ts
import { Message } from './types';

export class Sequencer {
    private sequences: Map<string, number> = new Map();

    getNext(room: string): number {
        const current = this.sequences.get(room) || 0;
        const next = current + 1;
        this.sequences.set(room, next);
        return next;
    }

    setCurrent(room: string, sequence: number): void {
        this.sequences.set(room, sequence);
    }

    getCurrent(room: string): number {
        return this.sequences.get(room) || 0;
    }

    sequenceMessage(message: Message): Message {
        const seq = this.getNext(message.room);
        return { ...message, sequence: seq };
    }
}

4. Gestionnaire de présence

// src/presence.ts
import { Client, Presence } from './types';

const HEARTBEAT_INTERVAL = 30000; // 30 secondes
const OFFLINE_TIMEOUT = 60000; // 60 secondes

export class PresenceManager {
    private users: Map<string, Presence> = new Map();
    private clients: Map<string, Client> = new Map();
    private intervals: Map<string, NodeJS.Timeout> = new Map();

    register(client: Client): void {
        this.clients.set(client.id, client);
        this.updatePresence(client.user, 'online');
        this.startHeartbeat(client);
    }

    unregister(client: Client): void {
        this.stopHeartbeat(client);
        this.clients.delete(client.id);
        this.updatePresence(client.user, 'offline');
    }

    updatePresence(user: string, status: 'online' | 'offline' | 'away'): void {
        this.users.set(user, {
            user,
            status,
            lastSeen: Date.now()
        });
    }

    getPresence(user: string): Presence | undefined {
        return this.users.get(user);
    }

    getOnlineUsers(): string[] {
        const now = Date.now();
        return Array.from(this.users.values())
            .filter(p => p.status === 'online' && (now - p.lastSeen) < OFFLINE_TIMEOUT)
            .map(p => p.user);
    }

    getPresenceInRoom(room: string): Presence[] {
        const now = Date.now();
        const usersInRoom = new Set<string>();

        for (const client of this.clients.values()) {
            if (client.rooms.has(room)) {
                usersInRoom.add(client.user);
            }
        }

        return Array.from(usersInRoom)
            .map(user => this.users.get(user)!)
            .filter(p => p && (now - p.lastSeen) < OFFLINE_TIMEOUT);
    }

    private startHeartbeat(client: Client): void {
        const interval = setInterval(() => {
            if (client.ws.readyState === client.ws.OPEN) {
                client.ws.send(JSON.stringify({ type: 'heartbeat' }));
                this.updatePresence(client.user, 'online');
            }
        }, HEARTBEAT_INTERVAL);

        this.intervals.set(client.id, interval);
    }

    private stopHeartbeat(client: Client): void {
        const interval = this.intervals.get(client.id);
        if (interval) {
            clearInterval(interval);
            this.intervals.delete(client.id);
        }
    }

    cleanup(): void {
        for (const interval of this.intervals.values()) {
            clearInterval(interval);
        }
        this.intervals.clear();
    }
}

5. Stockage de messages

// src/store.ts
import { Message } from './types';
import fs from 'fs/promises';
import path from 'path';

export class MessageStore {
    private basePath: string;

    constructor(basePath: string = './data/messages') {
        this.basePath = basePath;
    }

    async save(message: Message): Promise<void> {
        const roomPath = path.join(this.basePath, message.room);
        await fs.mkdir(roomPath, { recursive: true });

        const filename = path.join(roomPath, `${message.sequence}.json`);
        await fs.writeFile(filename, JSON.stringify(message, null, 2));
    }

    async getMessages(room: string, since: number = 0, limit: number = 100): Promise<Message[]> {
        const roomPath = path.join(this.basePath, room);
        const messages: Message[] = [];

        try {
            const files = await fs.readdir(roomPath);
            const jsonFiles = files
                .filter(f => f.endsWith('.json'))
                .map(f => parseInt(f.replace('.json', '')))
                .filter(seq => seq > since)
                .sort((a, b) => a - b)
                .slice(0, limit);

            for (const seq of jsonFiles) {
                const content = await fs.readFile(path.join(roomPath, `${seq}.json`), 'utf-8');
                messages.push(JSON.parse(content));
            }
        } catch (err) {
            // La salle n'existe pas encore
        }

        return messages;
    }

    async getLastSequence(room: string): Promise<number> {
        const roomPath = path.join(this.basePath, room);
        try {
            const files = await fs.readdir(roomPath);
            const sequences = files
                .filter(f => f.endsWith('.json'))
                .map(f => parseInt(f.replace('.json', '')));

            return sequences.length > 0 ? Math.max(...sequences) : 0;
        } catch {
            return 0;
        }
    }
}

6. Serveur WebSocket

// src/server.ts
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import { v4 as uuidv4 } from 'uuid';
import { PubSub } from './pub-sub';
import { Sequencer } from './sequencer';
import { PresenceManager } from './presence';
import { MessageStore } from './store';
import { Client, Message } from './types';

const PORT = process.env.PORT || 8080;

export class ChatServer {
    private wss: WebSocketServer;
    private pubSub: PubSub;
    private sequencer: Sequencer;
    private presence: PresenceManager;
    private store: MessageStore;

    constructor() {
        const server = createServer();
        this.wss = new WebSocketServer({ server });
        this.pubSub = new PubSub();
        this.sequencer = new Sequencer();
        this.presence = new PresenceManager();
        this.store = new MessageStore();

        this.setupHandlers();
    }

    private setupHandlers(): void {
        this.wss.on('connection', (ws: WebSocket) => {
            const clientId = uuidv4();
            const client: Client = {
                id: clientId,
                user: `user_${clientId.slice(0, 8)}`,
                rooms: new Set(),
                ws,
                lastSeen: Date.now()
            };

            console.log(`Client connected: ${client.id}`);

            ws.on('message', async (data: string) => {
                try {
                    const msg = JSON.parse(data);
                    await this.handleMessage(client, msg);
                } catch (err) {
                    console.error('Error handling message:', err);
                }
            });

            ws.on('close', () => {
                console.log(`Client disconnected: ${client.id}`);
                for (const room of client.rooms) {
                    this.pubSub.publish(room, {
                        id: uuidv4(),
                        room,
                        user: 'system',
                        content: `${client.user} left the room`,
                        sequence: this.sequencer.getCurrent(room),
                        timestamp: Date.now()
                    });
                    this.pubSub.unsubscribe(room, client);
                }
                this.presence.unregister(client);
            });

            // Envoyer un message de bienvenue
            this.sendToClient(client, {
                type: 'connected',
                data: { clientId: client.id, user: client.user }
            });

            this.presence.register(client);
        });
    }

    private async handleMessage(client: Client, msg: any): Promise<void> {
        switch (msg.type) {
            case 'join':
                await this.handleJoin(client, msg.room);
                break;
            case 'leave':
                this.handleLeave(client, msg.room);
                break;
            case 'message':
                await this.handleChatMessage(client, msg.data);
                break;
            case 'presence':
                this.handlePresenceRequest(client, msg.room);
                break;
            case 'history':
                await this.handleHistoryRequest(client, msg.room, msg.since);
                break;
            default:
                console.log('Unknown message type:', msg.type);
        }
    }

    private async handleJoin(client: Client, room: string): Promise<void> {
        console.log(`${client.user} joining room: ${room}`);

        // S'abonner à la salle
        this.pubSub.subscribe(room, client);

        // Envoyer la présence actuelle
        const presence = this.presence.getPresenceInRoom(room);
        this.sendToClient(client, {
            type: 'presence',
            data: { room, users: presence }
        });

        // Annoncer le rejoindre
        this.pubSub.publish(room, {
            id: uuidv4(),
            room,
            user: 'system',
            content: `${client.user} joined the room`,
            sequence: this.sequencer.getCurrent(room),
            timestamp: Date.now()
        });

        // Envoyer les messages récents
        const history = await this.store.getMessages(room, 0, 50);
        if (history.length > 0) {
            this.sendToClient(client, {
                type: 'history',
                data: { room, messages: history }
            });
        }
    }

    private handleLeave(client: Client, room: string): void {
        console.log(`${client.user} leaving room: ${room}`);
        this.pubSub.unsubscribe(room, client);

        this.pubSub.publish(room, {
            id: uuidv4(),
            room,
            user: 'system',
            content: `${client.user} left the room`,
            sequence: this.sequencer.getCurrent(room),
            timestamp: Date.now()
        });
    }

    private async handleChatMessage(client: Client, data: any): Promise<void> {
        const { room, content } = data;

        if (!client.rooms.has(room)) {
            this.sendError(client, 'Not subscribed to room');
            return;
        }

        const message: Message = {
            id: uuidv4(),
            room,
            user: client.user,
            content,
            sequence: 0, // Sera assigné
            timestamp: Date.now()
        };

        // Assigner un numéro de séquence
        const sequenced = this.sequencer.sequenceMessage(message);

        // Sauvegarder dans le stockage
        await this.store.save(sequenced);

        // Publier à tous les subscribers
        this.pubSub.publish(room, sequenced);

        console.log(`[${room}] ${client.user}: ${content} (seq: ${sequenced.sequence})`);
    }

    private handlePresenceRequest(client: Client, room: string): void {
        const presence = this.presence.getPresenceInRoom(room);
        this.sendToClient(client, {
            type: 'presence',
            data: { room, users: presence }
        });
    }

    private async handleHistoryRequest(client: Client, room: string, since: number = 0): Promise<void> {
        const messages = await this.store.getMessages(room, since);
        this.sendToClient(client, {
            type: 'history',
            data: { room, messages }
        });
    }

    private sendToClient(client: Client, data: any): void {
        if (client.ws.readyState === client.ws.OPEN) {
            client.ws.send(JSON.stringify(data));
        }
    }

    private sendError(client: Client, message: string): void {
        this.sendToClient(client, {
            type: 'error',
            data: { message }
        });
    }

    listen(): void {
        const server = this.wss.server!;
        server.listen(PORT, () => {
            console.log(`Chat server listening on port ${PORT}`);
        });
    }
}

7. Point d'entrée

// src/index.ts
import { ChatServer } from './server';

const server = new ChatServer();
server.listen();

8. Package.json

{
  "name": "chat-system",
  "version": "1.0.0",
  "description": "Real-time chat system with WebSockets",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts"
  },
  "dependencies": {
    "ws": "^8.18.0",
    "uuid": "^11.0.3"
  },
  "devDependencies": {
    "@types/node": "^22.10.2",
    "@types/ws": "^8.5.13",
    "@types/uuid": "^10.0.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.7.2"
  }
}

9. Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 8080

CMD ["npm", "start"]

10. Docker Compose

version: '3.8'

services:
  chat:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
    environment:
      - PORT=8080
    restart: unless-stopped

Implémentation Python

Structure du projet

chat-system/
├── requirements.txt
├── src/
│   ├── __init__.py
│   ├── types.py
│   ├── pub_sub.py
│   ├── sequencer.py
│   ├── presence.py
│   ├── store.py
│   ├── server.py
│   └── main.py
├── public/
│   └── client.html
├── Dockerfile
└── docker-compose.yml

1. Définitions de type

# src/types.py
from dataclasses import dataclass, field
from typing import Set
import websockets.server
import datetime

@dataclass
class Message:
    id: str
    room: str
    user: str
    content: str
    sequence: int
    timestamp: float

@dataclass
class Client:
    id: str
    user: str
    rooms: Set[str] = field(default_factory=set)
    websocket: websockets.server.WebSocketServerProtocol = None
    last_seen: float = field(default_factory=lambda: datetime.datetime.now().timestamp())

@dataclass
class Presence:
    user: str
    status: str  # 'online', 'offline', 'away'
    last_seen: float

2. Moteur Pub/Sub

# src/pub_sub.py
from typing import Dict, Set, List, Callable, Any
from .types import Message, Client

class PubSub:
    def __init__(self):
        self.subscriptions: Dict[str, Set[Client]] = {}
        self.handlers: Dict[str, List[Callable]] = {}

    def subscribe(self, room: str, client: Client) -> None:
        if room not in self.subscriptions:
            self.subscriptions[room] = set()
        self.subscriptions[room].add(client)
        client.rooms.add(room)

    def unsubscribe(self, room: str, client: Client) -> None:
        if room in self.subscriptions:
            self.subscriptions[room].discard(client)
            if not self.subscriptions[room]:
                del self.subscriptions[room]
        client.rooms.discard(room)

    async def publish(self, room: str, message: Message) -> None:
        if room in self.subscriptions:
            for client in self.subscriptions[room]:
                await self._send_to_client(client, message)
        await self._emit('message', message)

    async def _send_to_client(self, client: Client, message: Message) -> None:
        if client.websocket and not client.websocket.closed:
            import json
            await client.websocket.send(json.dumps({
                'type': 'message',
                'data': message.__dict__
            }))

    async def _emit(self, event: str, message: Message) -> None:
        handlers = self.handlers.get(event, [])
        for handler in handlers:
            await handler(None, message)

    def get_subscribers(self, room: str) -> List[Client]:
        return list(self.subscriptions.get(room, set()))

    def get_rooms(self) -> List[str]:
        return list(self.subscriptions.keys())

3. Gestionnaire de séquence

# src/sequencer.py
from typing import Dict
from .types import Message

class Sequencer:
    def __init__(self):
        self.sequences: Dict[str, int] = {}

    def get_next(self, room: str) -> int:
        current = self.sequences.get(room, 0)
        next_seq = current + 1
        self.sequences[room] = next_seq
        return next_seq

    def set_current(self, room: str, sequence: int) -> None:
        self.sequences[room] = sequence

    def get_current(self, room: str) -> int:
        return self.sequences.get(room, 0)

    def sequence_message(self, message: Message) -> Message:
        seq = self.get_next(message.room)
        message.sequence = seq
        return message

4. Gestionnaire de présence

# src/presence.py
import asyncio
import datetime
from typing import Dict, List, Set
from .types import Client, Presence

HEARTBEAT_INTERVAL = 30  # secondes
OFFLINE_TIMEOUT = 60  # secondes

class PresenceManager:
    def __init__(self):
        self.users: Dict[str, Presence] = {}
        self.clients: Dict[str, Client] = {}
        self.tasks: Dict[str, asyncio.Task] = {}

    def register(self, client: Client) -> None:
        self.clients[client.id] = client
        self.update_presence(client.user, 'online')
        self.tasks[client.id] = asyncio.create_task(self._heartbeat(client))

    def unregister(self, client: Client) -> None:
        if client.id in self.tasks:
            self.tasks[client.id].cancel()
            del self.tasks[client.id]
        if client.id in self.clients:
            del self.clients[client.id]
        self.update_presence(client.user, 'offline')

    def update_presence(self, user: str, status: str) -> None:
        self.users[user] = Presence(
            user=user,
            status=status,
            last_seen=datetime.datetime.now().timestamp()
        )

    def get_presence(self, user: str) -> Presence | None:
        return self.users.get(user)

    def get_online_users(self) -> List[str]:
        now = datetime.datetime.now().timestamp()
        return [
            p.user for p in self.users.values()
            if p.status == 'online' and (now - p.last_seen) < OFFLINE_TIMEOUT
        ]

    def get_presence_in_room(self, room: str) -> List[Presence]:
        now = datetime.datetime.now().timestamp()
        users_in_room = set()

        for client in self.clients.values():
            if room in client.rooms:
                users_in_room.add(client.user)

        return [
            self.users.get(user)
            for user in users_in_room
            if user in self.users and (now - self.users[user].last_seen) < OFFLINE_TIMEOUT
        ]

    async def _heartbeat(self, client: Client) -> None:
        import json
        while True:
            try:
                if client.websocket and not client.websocket.closed:
                    await client.websocket.send(json.dumps({'type': 'heartbeat'}))
                    self.update_presence(client.user, 'online')
            except asyncio.CancelledError:
                break
            except Exception:
                pass
            await asyncio.sleep(HEARTBEAT_INTERVAL)

    def cleanup(self) -> None:
        for task in self.tasks.values():
            task.cancel()
        self.tasks.clear()

5. Stockage de messages

# src/store.py
import os
import json
import asyncio
from pathlib import Path
from typing import List
from .types import Message

class MessageStore:
    def __init__(self, base_path: str = './data/messages'):
        self.base_path = Path(base_path)

    async def save(self, message: Message) -> None:
        room_path = self.base_path / message.room
        room_path.mkdir(parents=True, exist_ok=True)

        filename = room_path / f'{message.sequence}.json'
        with open(filename, 'w') as f:
            json.dump(message.__dict__, f, indent=2)

    async def get_messages(self, room: str, since: int = 0, limit: int = 100) -> List[Message]:
        room_path = self.base_path / room
        messages = []

        if not room_path.exists():
            return messages

        try:
            files = [f for f in os.listdir(room_path) if f.endswith('.json')]
            sequences = sorted([
                int(f.replace('.json', ''))
                for f in files
                if int(f.replace('.json', '')) > since
            ])[:limit]

            for seq in sequences:
                with open(room_path / f'{seq}.json', 'r') as f:
                    data = json.load(f)
                    messages.append(Message(**data))
        except FileNotFoundError:
            pass

        return messages

    async def get_last_sequence(self, room: str) -> int:
        room_path = self.base_path / room
        if not room_path.exists():
            return 0

        try:
            files = [f for f in os.listdir(room_path) if f.endswith('.json')]
            sequences = [int(f.replace('.json', '')) for f in files]
            return max(sequences) if sequences else 0
        except FileNotFoundError:
            return 0

6. Serveur WebSocket

# src/server.py
import websockets
import json
import uuid
import asyncio
from typing import Any
from .pub_sub import PubSub
from .sequencer import Sequencer
from .presence import PresenceManager
from .store import MessageStore
from .types import Client, Message

PORT = int(os.getenv('PORT', 8080))

class ChatServer:
    def __init__(self):
        self.pub_sub = PubSub()
        self.sequencer = Sequencer()
        self.presence = PresenceManager()
        self.store = MessageStore()

    async def handle_client(self, websocket, path):
        client_id = str(uuid.uuid4())
        client = Client(
            id=client_id,
            user=f"user_{client_id[:8]}",
            websocket=websocket,
            rooms=set()
        )

        print(f"Client connected: {client.id}")

        await self._send_to_client(client, {
            'type': 'connected',
            'data': {'clientId': client.id, 'user': client.user}
        })

        self.presence.register(client)

        try:
            async for message in websocket:
                msg = json.loads(message)
                await self.handle_message(client, msg)
        except websockets.exceptions.ConnectionClosed:
            print(f"Client disconnected: {client.id}")
        finally:
            for room in list(client.rooms):
                await self.pub_sub.publish(room, Message(
                    id=str(uuid.uuid4()),
                    room=room,
                    user='system',
                    content=f"{client.user} left the room",
                    sequence=self.sequencer.get_current(room),
                    timestamp=asyncio.get_event_loop().time()
                ))
                self.pub_sub.unsubscribe(room, client)
            self.presence.unregister(client)

    async def handle_message(self, client: Client, msg: Any) -> None:
        handlers = {
            'join': self.handle_join,
            'leave': self.handle_leave,
            'message': self.handle_chat_message,
            'presence': self.handle_presence_request,
            'history': self.handle_history_request
        }

        handler = handlers.get(msg.get('type'))
        if handler:
            await handler(client, msg)
        else:
            print(f"Unknown message type: {msg.get('type')}")

    async def handle_join(self, client: Client, msg: Any) -> None:
        room = msg.get('room')
        print(f"{client.user} joining room: {room}")

        self.pub_sub.subscribe(room, client)

        presence = self.presence.get_presence_in_room(room)
        await self._send_to_client(client, {
            'type': 'presence',
            'data': {'room': room, 'users': [p.__dict__ for p in presence]}
        })

        await self.pub_sub.publish(room, Message(
            id=str(uuid.uuid4()),
            room=room,
            user='system',
            content=f"{client.user} joined the room",
            sequence=self.sequencer.get_current(room),
            timestamp=asyncio.get_event_loop().time()
        ))

        history = await self.store.get_messages(room, 0, 50)
        if history:
            await self._send_to_client(client, {
                'type': 'history',
                'data': {'room': room, 'messages': [m.__dict__ for m in history]}
            })

    def handle_leave(self, client: Client, msg: Any) -> None:
        room = msg.get('room')
        print(f"{client.user} leaving room: {room}")
        self.pub_sub.unsubscribe(room, client)

    async def handle_chat_message(self, client: Client, msg: Any) -> None:
        data = msg.get('data', {})
        room = data.get('room')

        if room not in client.rooms:
            await self._send_error(client, 'Not subscribed to room')
            return

        message = Message(
            id=str(uuid.uuid4()),
            room=room,
            user=client.user,
            content=data.get('content', ''),
            sequence=0,
            timestamp=asyncio.get_event_loop().time()
        )

        sequenced = self.sequencer.sequence_message(message)
        await self.store.save(sequenced)
        await self.pub_sub.publish(room, sequenced)

        print(f"[{room}] {client.user}: {sequenced.content} (seq: {sequenced.sequence})")

    async def handle_presence_request(self, client: Client, msg: Any) -> None:
        room = msg.get('room')
        presence = self.presence.get_presence_in_room(room)
        await self._send_to_client(client, {
            'type': 'presence',
            'data': {'room': room, 'users': [p.__dict__ for p in presence]}
        })

    async def handle_history_request(self, client: Client, msg: Any) -> None:
        room = msg.get('room')
        since = msg.get('since', 0)
        messages = await self.store.get_messages(room, since)
        await self._send_to_client(client, {
            'type': 'history',
            'data': {'room': room, 'messages': [m.__dict__ for m in messages]}
        })

    async def _send_to_client(self, client: Client, data: Any) -> None:
        if client.websocket and not client.websocket.closed:
            await client.websocket.send(json.dumps(data))

    async def _send_error(self, client: Client, message: str) -> None:
        await self._send_to_client(client, {
            'type': 'error',
            'data': {'message': message}
        })

    async def start(self):
        print(f"Chat server listening on port {PORT}")
        async with websockets.serve(self.handle_client, "", PORT):
            await asyncio.Future()  # Run forever

7. Point d'entrée

# src/main.py
import asyncio
import os
from server import ChatServer

async def main():
    server = ChatServer()
    await server.start()

if __name__ == '__main__':
    asyncio.run(main())

8. Configuration requise

websockets==13.1
aiofiles==24.1.0

9. Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8080

CMD ["python", "src/main.py"]

10. Docker Compose

version: '3.8'

services:
  chat:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./data:/app/data
    environment:
      - PORT=8080
    restart: unless-stopped

Exécution du système de chat

TypeScript

# Installer les dépendances
npm install

# Compiler
npm run build

# Démarrer le serveur
npm start

# Avec Docker Compose
docker-compose up

Python

# Installer les dépendances
pip install -r requirements.txt

# Démarrer le serveur
python src/main.py

# Avec Docker Compose
docker-compose up

Exercices

Exercice 1 : Opérations de chat de base

  1. Démarrer le serveur de chat
  2. Connecter deux clients WebSocket
  3. Rejoindre la même salle
  4. Envoyer des messages et vérifier que les deux clients les reçoivent
  5. Quitter la salle et vérifier la diffusion

Exercice 2 : Ordonnancement des messages

  1. Envoyer plusieurs messages rapidement depuis différents clients
  2. Vérifier que tous les messages ont des numéros de séquence uniques et séquentiels
  3. Déconnecter et reconnecter un client
  4. Demander l'historique des messages et vérifier que l'ordonnancement est préservé

Exercice 3 : Gestion de la présence

  1. Connecter plusieurs clients à différentes salles
  2. Rejoindre une salle et vérifier les diffusions de présence
  3. Simuler une défaillance réseau (tuer un client sans partir correctement)
  4. Vérifier que la détection hors ligne intervient après le délai d'attente

Exercice 4 : Persistance des messages

  1. Envoyer des messages à une salle
  2. Arrêter le serveur
  3. Vérifier que les messages sont sauvegardés sur disque
  4. Redémarrer le serveur
  5. Connecter un nouveau client et vérifier qu'il reçoit l'historique des messages

Pièges courants

ProblèmeCauseSolution
Messages non ordonnésNuméros de séquence manquantsToujours séquencer avant de publier
Anciens messages non reçusPas demander l'historique lors de la jointureImplémenter la relecture à la connexion
La présence affiche hors ligneHeartbeat non envoyéS'assurer que la boucle heartbeat fonctionne
Messages en doubleRepublication des messages sauvegardésPublier uniquement les nouveaux messages, pas l'historique

Points clés à retenir

  • Pub/Sub permet la communication multi-salle extensible
  • Les numéros de séquence garantissent l'ordonnancement des messages sur tous les clients
  • La gestion de présence nécessite à la fois des heartbeats actifs et une détection de délai d'attente passive
  • La persistance des messages permet aux clients de se reconnecter et de recevoir l'historique
  • Docker Compose simplifie le déploiement et les tests du système complet

🧠 Quiz du chapitre

Testez votre maîtrise de ces concepts ! Ces questions mettront au défi votre compréhension et révéleront les lacunes dans vos connaissances.

Qu'est-ce que le Consensus ?

Session 8 - Session complète

Objectifs d'Apprentissage

  • Comprendre le problème du consensus dans les systèmes distribués
  • Apprendre la différence entre les propriétés de sécurité et de vivacité
  • Explorer le résultat d'impossibilité FLP
  • Comprendre pourquoi les algorithmes de consensus sont nécessaires
  • Comparer les approches Raft et Paxos

Le Problème du Consensus

Dans les systèmes distribués, le consensus (consensus) est le problème consistant à faire s'accorder plusieurs nœuds sur une seule valeur. Cela semble simple, mais c'est fondamental pour construire des systèmes distribués fiables.

Pourquoi avons-nous besoin du Consensus ?

Considérez ces scénarios :

  • Élection de Leader (Leader Election) : Plusieurs nœuds doivent s'accorder sur qui est le leader
  • Changements de Configuration : Tous les nœuds doivent s'accorder sur une nouvelle configuration
  • Machines à États Répliquées : Tous les nœuds doivent appliquer les opérations dans le même ordre
  • Transactions Distribuées : Tous les participants doivent s'accorder pour valider ou abandonner

Sans consensus, les systèmes distribués peuvent souffrir de :

  • Scénarios de split-brain (multiple leaders)
  • État incohérent entre les nœuds
  • Corruption de données due à des écritures conflictuelles
  • Systèmes indisponibles pendant les partitions réseau
graph LR
    subgraph "Sans Consensus"
        N1[Nœud A : valeur=1]
        N2[Nœud B : valeur=2]
        N3[Nœud C : valeur=3]
        N1 --- N2 --- N3
        Problem[Quelle valeur est correcte ?]
    end

    subgraph "Avec Consensus"
        A1[Nœud A : valeur=2]
        A2[Nœud B : valeur=2]
        A3[Nœud C : valeur=2]
        A1 --- A2 --- A3
        Solved[Tous les nœuds s'accordent]
    end

Définition Formelle

Le problème du consensus exige qu'un système satisfasse ces propriétés :

1. Accord (Sécurité)

Tous les nœuds corrects doivent s'accorder sur la même valeur.

Si le nœud A produit v et le nœud B produit v', alors v = v'

2. Validité

Si tous les nœuds corrects proposent la même valeur v, alors tous les nœuds corrects décident v.

La valeur décidée doit avoir été proposée par un certain nœud

3. Terminaison (Vivacité)

Tous les nœuds corrects décident finalement d'une certaine valeur.

L'algorithme doit progresser, pas tourner pour toujours

4. Intégrité

Chaque nœud décide au plus une fois.

Un nœud ne peut pas changer sa décision après avoir décidé


Sécurité vs Vivacité

Comprendre le compromis entre la sécurité (safety) et la vivacité (liveness) est crucial pour les systèmes distribués :

graph TB
    subgraph "Propriétés de Sécurité"
        S1[Accord]
        S2[Validité]
        S3[Intégrité]
        style S1 fill:#90EE90
        style S2 fill:#90EE90
        style S3 fill:#90EE90
    end

    subgraph "Propriétés de Vivacité"
        L1[Terminaison]
        L2[Progrès]
        style L1 fill:#FFB6C1
        style L2 fill:#FFB6C1
    end

    Safety["Rien de mauvais n'arrive<br/>L'état est toujours cohérent"]
    Liveness["Quelque chose de bon arrive<br/>Le système progresse"]

    S1 & S2 & S3 --> Safety
    L1 & L2 --> Liveness

    Safety --> Tradeoff["Dans les réseaux,<br/>vous ne pouvez pas garantir les deux<br/>pendant les partitions"]
    Liveness --> Tradeoff
Sécurité (Safety)Vivacité (Liveness)
"Rien de mauvais n'arrive""Quelque chose de bon arrive"
L'état est toujours valideLe système progresse
Pas de corruption, pas de conflitsLes opérations se terminent finalement
Peut être maintenue pendant les partitionsPeut être sacrifiée pendant les partitions

Exemple : Pendant une partition réseau (théorème CAP), un système CP maintient la sécurité (pas d'écritures incohérentes) mais sacrifie la vivacité (les écritures peuvent être rejetées). Un système AP maintient la vivacité (les écritures réussissent) mais peut sacrifier la sécurité (incohérences temporaires).


Pourquoi le Consensus est Difficile

Défi 1 : Pas d'Horloge Globale

Les nœuds ne partagent pas d'horloge synchronisée, ce qui rend difficile l'ordonnancement des événements :

sequenceDiagram
    participant A as Nœud A (t=10:00:01)
    participant B as Nœud B (t=10:00:05)
    participant C as Nœud C (t=10:00:03)

    Note over A: A propose valeur=1
    A->>B: send(valeur=1)
    Note over B: B reçoit à t=10:00:07

    Note over C: C propose valeur=2
    C->>B: send(valeur=2)
    Note over B: B reçoit à t=10:00:08

    Note over B: Quelle valeur est arrivée en premier ?

Défi 2 : Perte et Retards de Messages

Les messages peuvent être perdus, retardés ou réordonnés :

stateDiagram-v2
    [*] --> Envoyé: Le nœud envoie un message
    Envoyé --> Livré: Le message arrive
    Envoyé --> Perdu: Message perdu
    Envoyé --> Retardé: Réseau lent
    Retardé --> Livré: Arrive finalement
    Perdu --> Réessayer: Le nœud renvoie
    Livré --> [*]

Défi 3 : Pannes de Nœuds

Les nœuds peuvent planter à tout moment, potentiellement en détenant des informations critiques :

graph TB
    subgraph "État du Cluster"
        N1[Nœud 1 : En vie]
        N2[Nœud 2 : PLANTÉ<br/>Avait des données non validées]
        N3[Nœud 3 : En vie]
        N4[Nœud 4 : En vie]

        N1 --- N2
        N2 --- N3
        N3 --- N4
    end

    Q[Que se passe-t-il pour<br/>les données du Nœud 2 ?]

Le Résultat d'Impossibilité FLP

En 1985, Fischer, Lynch et Paterson ont prouvé le Résultat d'Impossibilité FLP :

Dans un réseau asynchrone, même avec un seul nœud défectueux, aucun algorithme de consensus déterministe ne peut garantir la sécurité, la vivacité et la terminaison.

Ce que cela signifie

graph TB
    A[Réseau Asynchrone] --> B[Pas d'hypothèses de synchronisation]
    B --> C[Les messages peuvent prendre arbitrairement longtemps]
    C --> D[Impossible de distinguer un nœud lent d'un nœud planté]
    D --> E[Impossible de garantir la terminaison]
    E --> F[FLP : Consensus impossible<br/>dans les systèmes purement asynchrones]

Comment nous contournons cela

Les systèmes réels gèrent FLP en relaxant certaines hypothèses :

  1. Synchronisme Partiel : Supposer que les réseaux sont finalement synchrones
  2. Randomisation : Utiliser des algorithmes randomisés (ex: délais d'élection randomisés)
  3. Détecteurs de Panne : Utiliser des détecteurs de panne non fiables
  4. Délais d'Attente : Supposer que les messages arrivent dans un certain délai

Aperçu Clé : Raft fonctionne dans des systèmes "partiellement synchrones" — les réseaux peuvent se comporter de manière asynchrone pendant un moment, mais deviennent finalement synchrones.


Scénarios Réels de Consensus

Scénario 1 : Configuration Distribuée

Tous les nœuds doivent s'accorder sur l'appartenance au cluster :

sequenceDiagram
    autonumber
    participant N1 as Nœud 1
    participant N2 as Nœud 2
    participant N3 as Nœud 3
    participant N4 as Nouveau Nœud

    N4->>N1: Demande de rejoindre
    N1->>N2: Proposer d'ajouter le Nœud 4
    N1->>N3: Proposer d'ajouter le Nœud 4

    N2->>N1: Vote OUI
    N3->>N1: Vote OUI

    N1->>N2: Valider : ajouter le Nœud 4
    N1->>N3: Valider : ajouter le Nœud 4
    N1->>N4: Vous êtes dedans !

    Note over N1,N4: Tous les nœuds s'accordent maintenant<br/>le cluster a 4 membres

Scénario 2 : Machine à États Répliquée

Toutes les répliques doivent appliquer les opérations dans le même ordre :

graph LR
    C[Client] --> L[Leader]

    subgraph "Journal Répliqué"
        L1[Leader : SET x=1]
        F1[Suiveur 1 : SET x=1]
        F2[Suiveur 2 : SET x=1]
        F3[Suiveur 3 : SET x=1]
        L1 --- F1 --- F2 --- F3
    end

    subgraph "Machine à États"
        S1[Leader : x=1]
        S2[Suiveur 1 : x=1]
        S3[Suiveur 2 : x=1]
        S4[Suiveur 3 : x=1]
    end

    L --> L1
    F1 --> S2
    F2 --> S3
    F3 --> S4

Algorithmes de Consensus : Raft vs Paxos

Paxos (1998)

Paxos fut le premier algorithme de consensus pratique, mais il est notoirement difficile à comprendre :

Phase 1a (Préparer) :  Le proposant choisit le numéro de proposition n, envoie Prepare(n)
Phase 1b (Promesse) :  L'accepteur promet de ne pas accepter les propositions < n
Phase 2a (Accepter) :  Le proposant envoie Accept(n, valeur)
Phase 2b (Accepté) :  L'accepteur accepte si aucune proposition supérieure vue

Avantages :

  • Preuve de correction
  • Gère n'importe quel nombre de pannes
  • Complexité de message minimale

Inconvénients :

  • Extrêmement difficile à comprendre
  • Difficile à implémenter correctement
  • Multi-Paxos ajoute de la complexité
  • Pas de leader par défaut

Raft (2014)

Raft a été conçu spécifiquement pour la compréhension :

graph TB
    subgraph "Composants Raft"
        LE[Élection de Leader]
        LR[Réplication de Journal]
        SM[Machine à États]
        Safety[Propriétés de Sécurité]

        LE --> LR
        LR --> SM
        Safety --> LE
        Safety --> LR
    end

Avantages :

  • Conçu pour la compréhension
  • Séparation claire des préoccupations
  • Leader fort simplifie la logique
  • Guide d'implémentation pratique
  • Large adoption

Inconvénients :

  • Le leader peut être un goulot d'étranglement
  • Pas aussi optimisé que les variantes Multi-Paxos

Quand avez-vous besoin du Consensus ?

Utilisez le consensus lorsque :

ScénarioBesoin de Consensus ?Raison
Base de données à nœud uniqueNonPas d'état distribué
Réplication multi-maîtreOuiDoit s'accorder sur l'ordre des écritures
Élection de leaderOuiDoit s'accorder sur qui est le leader
Gestion de configurationOuiTous les nœuds ont besoin de la même config
Service de verrou distribuéOuiDoit s'accorder sur le détenteur du verrou
État du répartiteur de chargeNonSans état, peut être reconstruit
Invalidation de cacheParfoisDépend des besoins de cohérence

Quand vous N'avez PAS besoin du Consensus

  • Systèmes en lecture seule : Pas d'état sur lequel s'accorder
  • La cohérence éventuelle suffit : Last-write-wins suffit
  • Types de données répliquées sans conflit (CRDT) : Résoudre mathématiquement les conflits
  • Source unique de vérité : Autorité centralisée

Exemple Simple de Consensus

Examinons un scénario de consensus simplifié : s'accorder sur une valeur de compteur.

Exemple TypeScript

// Une simulation de consensus simple
interface Proposal {
  value: number;
  proposerId: string;
}

class ConsensusNode {
  private proposals: Map<string, Proposal> = new Map();
  private decidedValue?: number;
  private nodeId: string;

  constructor(nodeId: string) {
    this.nodeId = nodeId;
  }

  // Proposer une valeur
  propose(value: number): void {
    const proposal: Proposal = {
      value,
      proposerId: this.nodeId
    };
    this.proposals.set(this.nodeId, proposal);
    this.broadcastProposal(proposal);
  }

  // Recevoir une proposition d'un autre nœud
  receiveProposal(proposal: Proposal): void {
    this.proposals.set(proposal.proposerId, proposal);
    this.checkConsensus();
  }

  // Vérifier si nous avons un consensus
  private checkConsensus(): void {
    if (this.decidedValue !== undefined) return;

    const values = Array.from(this.proposals.values()).map(p => p.value);
    const counts = new Map<number, number>();

    for (const value of values) {
      counts.set(value, (counts.get(value) || 0) + 1);
    }

    // Consensus à majorité simple
    for (const [value, count] of counts.entries()) {
      if (count > Math.floor(this.proposals.size / 2)) {
        this.decidedValue = value;
        console.log(`Nœud ${this.nodeId} a décidé de la valeur : ${value}`);
        return;
      }
    }
  }

  private broadcastProposal(proposal: Proposal): void {
    // Dans un système réel, cela enverrait aux autres nœuds
    console.log(`Nœud ${this.nodeId} diffuse la proposition : ${proposal.value}`);
  }
}

// Exemple d'utilisation
const node1 = new ConsensusNode('node1');
const node2 = new ConsensusNode('node2');
const node3 = new ConsensusNode('node3');

node1.propose(42);
node2.propose(42);
node3.propose(99);  // Minorité, devrait perdre

Exemple Python

from dataclasses import dataclass
from typing import Optional, Dict
import random

@dataclass
class Proposal:
    value: int
    proposer_id: str

class ConsensusNode:
    def __init__(self, node_id: str):
        self.node_id = node_id
        self.proposals: Dict[str, Proposal] = {}
        self.decided_value: Optional[int] = None

    def propose(self, value: int) -> None:
        """Proposer une valeur au groupe."""
        proposal = Proposal(value, self.node_id)
        self.proposals[self.node_id] = proposal
        self._broadcast_proposal(proposal)
        self._check_consensus()

    def receive_proposal(self, proposal: Proposal) -> None:
        """Recevoir une proposition d'un autre nœud."""
        self.proposals[proposal.proposer_id] = proposal
        self._check_consensus()

    def _check_consensus(self) -> None:
        """Vérifier si nous avons un consensus sur une valeur."""
        if self.decided_value is not None:
            return

        if not self.proposals:
            return

        # Compter les occurrences de chaque valeur
        counts = {}
        for proposal in self.proposals.values():
            counts[proposal.value] = counts.get(proposal.value, 0) + 1

        # Consensus à majorité simple
        total_nodes = len(self.proposals)
        for value, count in counts.items():
            if count > total_nodes // 2:
                self.decided_value = value
                print(f"Nœud {self.node_id} a décidé de la valeur : {value}")
                return

    def _broadcast_proposal(self, proposal: Proposal) -> None:
        """Diffuser la proposition aux autres nœuds."""
        print(f"Nœud {self.node_id} diffuse la proposition : {proposal.value}")

# Exemple d'utilisation
if __name__ == "__main__":
    node1 = ConsensusNode("node1")
    node2 = ConsensusNode("node2")
    node3 = ConsensusNode("node3")

    node1.propose(42)
    node2.propose(42)
    node3.propose(99)  # Minorité, devrait perdre

Pièges Courants

PiègeDescriptionSolution
Split BrainPlusieurs leaders pensent qu'ils sont en chargeUtiliser un vote à quorum
Lectures StaléesLire à partir de nœuds qui n'ont pas reçu les mises à jourLire à partir du leader ou utiliser des lectures à quorum
Gestion de Partition RéseauLes nœuds ne peuvent pas communiquer mais continuent à fonctionnerExiger un quorum pour les opérations
Pannes PartiellesCertains nœuds plantent, d'autres continuentConcevoir pour la tolérance aux pannes
Dérive d'HorlogeDes horloges différentes causent des problèmes d'ordonnancementUtiliser des horloges logiques (horodatages Lamport)

Résumé

Points Clés à Retenir

  1. Le Consensus est le problème consistant à faire s'accorder plusieurs nœuds distribués sur une seule valeur
  2. La Sécurité (Safety) assure que rien de mauvais n'arrive (accord, validité, intégrité)
  3. La Vivacité (Liveness) assure que quelque chose de bon arrive (terminaison, progrès)
  4. L'Impossibilité FLP prouve que le consensus est impossible dans les systèmes purement asynchrones
  5. Les Systèmes réels contournent FLP en utilisant le synchronisme partiel et les délais d'attente
  6. Raft a été conçu pour la compréhension, contrairement à l'algorithme Paxos complexe

Prochaine Session

Dans la prochaine session, nous plongerons dans l'algorithme Raft lui-même :

  • La philosophie de conception de Raft
  • États des nœuds (Follower, Candidate, Leader)
  • Le fonctionnement de l'élection de leader
  • Comment la réplication de journal maintient la cohérence

Exercices

  1. Sécurité vs Vivacité : Donnez un exemple d'un système qui privilégie la sécurité à la vivacité, et un qui fait l'inverse.

  2. Scénario FLP : Décrivez un scénario où FLP causerait des problèmes dans un système distribué réel.

  3. Besoin de Consensus : Pour chacun de ces systèmes, expliquez s'ils ont besoin de consensus et pourquoi :

    • Un magasin clé-valeur distribué
    • Un CDN (réseau de diffusion de contenu)
    • Une file de tâches distribuée
    • Un système blockchain
  4. Consensus Simple : Étendez l'exemple de consensus simple pour gérer les pannes de nœuds (un nœud cesse de répondre).

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.

L'Algorithme Raft

Session 9, Partie 1 - 25 minutes

Objectifs d'Apprentissage

  • Comprendre la philosophie de conception de Raft
  • Apprendre les trois états d'un nœud Raft
  • Explorer comment Raft gère le consensus à travers l'élection de leader et la réplication de journal
  • Comprendre le concept des termes dans Raft
  • Apprendre les propriétés de sécurité de Raft

Philosophie de Conception de Raft

Raft a été conçu par Diego Ongaro et John Ousterhout en 2014 avec un objectif spécifique : la compréhension. Contrairement à Paxos, qui était notoirement difficile à comprendre et à implémenter correctement, Raft sépare le problème du consensus en sous-problèmes clairs et gérables.

Principes de Conception Fondamentaux

  1. Leader Fort : Raft utilise une approche de leader fort — toutes les entrées de journal passent par le leader
  2. Complétude du Leader : Une fois qu'une entrée de journal est validée, elle reste dans le journal de tous les futurs leaders
  3. Décomposition : Diviser le consensus en trois sous-problèmes :
    • Élection de leader
    • Réplication de journal
    • Sécurité

Pourquoi "Raft" ?

Le nom est une analogie : un radeau (l'algorithme) garde tous les nœuds (journaux) ensemble et se déplaçant dans la même direction, tout comme un radeau garde les gens ensemble sur l'eau.


Aperçu de Raft

graph TB
    subgraph "Consensus Raft"
        Client[Client]

        subgraph "Cluster"
            L[Leader]
            F1[Suiveur 1]
            F2[Suiveur 2]
            F3[Suiveur 3]

            L --> F1
            L --> F2
            L --> F3
        end

        Client -->|Demande d'écriture| L
        L -->|AppendEntries| F1 & F2 & F3
        F1 & F2 & F3 -->|Ack| L
        L -->|Réponse| Client
    end

Concepts Clés

ConceptDescription
LeaderLe seul nœud qui gère les demandes clientes et ajoute des entrées au journal
Suiveur (Follower)Nœuds passifs qui répliquent le journal du leader
CandidatUn nœud qui fait campagne pour devenir leader lors d'une élection
TermeUne horloge logique divisée en termes de longueur arbitraire
Journal (Log)Une séquence d'entrées contenant des commandes à appliquer à la machine à états

États des Nœuds

Chaque nœud Raft peut être dans l'un des trois états :

stateDiagram-v2
    [*] --> Suiveur: Le nœud démarre

    Suiveur --> Candidat: Délai d'élection expire<br/>aucun RPC valide reçu
    Candidat --> Leader: Reçoit les votes de la majorité
    Candidat --> Suiveur: Découvre le leader actuel<br/>ou terme supérieur
    Leader --> Suiveur: Découvre un terme supérieur

    Suiveur --> Suiveur: Reçoit AppendEntries/RPC valide<br/>du leader ou candidat

    note right of Suiveur
        - Répond aux RPCs
        - Pas de RPC sortants
        - Délai d'élection en cours
    end note

    note right of Candidat
        - Demander des votes
        - Délai d'élection en cours
        - Peut devenir leader ou suiveur
    end note

    note right of Leader
        - Gère toutes les demandes clientes
        - Envoie des battements de cœur aux suiveurs
        - Pas de délai (actif)
    end note

Descriptions d'États

Suiveur (Follower)

  • État par défaut pour tous les nœuds
  • Reçoit passivement les entrées du leader
  • Répond aux RPCs (RequestVote, AppendEntries)
  • Si aucune communication pendant le délai d'élection, devient candidat

Candidat

  • Fait campagne pour devenir leader
  • Incrémente le terme actuel
  • Vote pour lui-même
  • Envoie des RPCs RequestVote à tous les autres nœuds
  • Devient leader s'il reçoit les votes de la majorité
  • Retourne à l'état de suiveur s'il découvre le leader actuel ou un terme supérieur

Leader

  • Gère toutes les demandes clientes
  • Envoie des RPCs AppendEntries à tous les suiveurs (battements de cœur)
  • Valide les entrées une fois répliquées sur la majorité
  • Descend s'il découvre un terme supérieur

Termes

Un terme est le mécanisme de temps logique de Raft :

timeline
    title Termes Raft
    Terme 1 : Leader A élu
           : Fonctionnement normal
           : Le leader A plante

    Terme 2 : L'élection commence
           : Vote partagé !
           : Délai, nouvelle élection

    Terme 3 : Leader B élu
           : Fonctionnement normal

Propriétés des Termes

  1. Croissance Monotone : Les termes augmentent toujours, ne diminuent jamais
  2. Terme Actuel : Chaque nœud stocke le numéro de terme actuel
  3. Transitions de Terme :
    • Les nœuds incrémentent le terme lorsqu'ils deviennent candidats
    • Les nœuds mettent à jour le terme lorsqu'ils reçoivent un message de terme supérieur
    • Lorsque le terme change, le nœud devient suiveur

Terme dans les Messages

sequenceDiagram
    participant C as Candidat
    participant F1 as Suiveur (terme=3)
    participant F2 as Suiveur (terme=4)

    C->>F1: RequestVote(terme=5)
    Note over F1: Voit un terme supérieur
    F1-->>C: Vote OUI (passe à terme=5)

    C->>F2: RequestVote(terme=5)
    Note over F2: Déjà à un terme supérieur
    F2-->>C: Vote NON (mon terme est supérieur)

Approche en Deux Phases de Raft

Raft atteint le consensus à travers deux phases principales :

Phase 1 : Élection de Leader

sequenceDiagram
    autonumber
    participant F1 as Suiveur 1
    participant F2 as Suiveur 2
    participant F3 as Suiveur 3

    Note over F1,F3: Délai d'élection expire

    F1->>F1: Devient Candidat (terme=1)
    F1->>F2: RequestVote(terme=1)
    F1->>F3: RequestVote(terme=1)

    F2-->>F1: Accorder le vote (terme=1)
    F3-->>F1: Accorder le vote (terme=1)

    Note over F1: Majorité gagnée !
    F1->>F1: Devient Leader
    F1->>F2: AppendEntries (battement de cœur)
    F1->>F3: AppendEntries (battement de cœur)

Phase 2 : Réplication de Journal

sequenceDiagram
    autonumber
    participant C as Client
    participant L as Leader
    participant F1 as Suiveur 1
    participant F2 as Suiveur 2

    C->>L: SET x=5

    L->>L: Ajouter au journal (index=10, terme=1)
    L->>F1: AppendEntries(entry: SET x=5)
    L->>F2: AppendEntries(entry: SET x=5)

    F1-->>L: Succès (répliqué)
    F2-->>L: Succès (répliqué)

    Note over L: Majorité répliquée !<br/>Valider l'entrée

    L->>L: Appliquer à la machine à états : x=5
    L-->>C: Réponse : OK

Propriétés de Sécurité

Raft garantit plusieurs propriétés de sécurité importantes :

1. Sécurité d'Élection

Au plus un leader peut être élu par terme.

Comment : Chaque nœud vote au plus une fois par terme, et un candidat a besoin de la majorité des votes.

graph TB
    subgraph "Même Terme - Un Seul Leader"
        T[Terme 5]
        C1[Candidat A : 2 votes]
        C2[Candidat B : 1 vote]
        C1 -->|gagne la majorité| L[Leader A]
        style L fill:#90EE90
    end

2. Ajout-Seulement du Leader

Un leader ne jamais écrase ou supprime les entrées de son journal ; il ajoute seulement.

Comment : Les leaders ajoutent toujours de nouvelles entrées à la fin de leur journal.

3. Correspondance de Journal

Si deux journaux contiennent une entrée avec le même index et terme, alors toutes les entrées précédentes sont identiques.

graph LR
    subgraph "Journal du Leader"
        L1[index 1, terme 1: SET a=1]
        L2[index 2, terme 1: SET b=2]
        L3[index 3, terme 2: SET c=3]
        L1 --> L2 --> L3
    end

    subgraph "Journal du Suiveur"
        F1[index 1, terme 1: SET a=1]
        F2[index 2, terme 1: SET b=2]
        F3[index 3, terme 2: SET c=3]
        F4[index 4, terme 2: SET d=4]
        F1 --> F2 --> F3 --> F4
    end

    Match[Entrées 1-3 correspondent !<br/>Le suiveur peut avoir des entrées supplémentaires]

4. Complétude du Leader

Si une entrée de journal est validée dans un terme donné, elle sera présente dans les journaux de tous les leaders pour les termes supérieurs.

Comment : Un candidat doit avoir toutes les entrées validées avant de pouvoir gagner une élection.

5. Sécurité de la Machine à États

Si un serveur a appliqué une entrée de journal à un index donné à sa machine à états, aucun autre serveur n'appliquera jamais une entrée de journal différente pour le même index.


RPCs Raft

Raft utilise deux types principaux de RPC :

RPC RequestVote

interface RequestVoteArgs {
  term: number;           // Terme du candidat
  candidateId: string;    // Candidat demandant le vote
  lastLogIndex: number;   // Index de la dernière entrée de journal du candidat
  lastLogTerm: number;    // Terme de la dernière entrée de journal du candidat
}

interface RequestVoteReply {
  term: number;           // Terme actuel (pour que le candidat mette à jour)
  voteGranted: boolean;   // Vrai si le candidat a reçu le vote
}

Règles de Vote :

  1. Si term < currentTerm : refuser le vote
  2. Si votedFor est null ou candidateId : accorder le vote
  3. Si le journal du candidat est au moins à jour : accorder le vote

RPC AppendEntries

interface AppendEntriesArgs {
  term: number;           // Terme du leader
  leaderId: string;       // Pour que le suiveur puisse rediriger les clients
  prevLogIndex: number;   // Index de l'entrée de journal précédant les nouvelles
  prevLogTerm: number;    // Terme de l'entrée prevLogIndex
  entries: LogEntry[];    // Entrées de journal à stocker (vide pour battement de cœur)
  leaderCommit: number;   // Index de validation du leader
}

interface AppendEntriesReply {
  term: number;           // Terme actuel (pour que le leader mette à jour)
  success: boolean;       // Vrai si le suiveur avait l'entrée correspondant à prevLogIndex
}

Utilisé pour les deux :

  • Réplication de journal : Envoyer de nouvelles entrées
  • Battements de cœur : Entrées vides pour maintenir l'autorité

Propriété de Complétude de Journal

Lors du vote, les nœuds comparent la complétude du journal :

graph TB
    subgraph "Comparaison des Journaux"
        A[Journal du Candidat]
        B[Journal du Suiveur]

        A --> A1[Dernier index : 10, terme : 5]
        B --> B1[Dernier index : 9, terme : 5]

        Result[Le journal A est plus à jour<br/>car l'index 10 > 9]
    end

    subgraph "Règle de Décision"
        C[Candidat : dernier terme=5]
        D[Suiveur : dernier terme=6]

        Result2[Le suiveur est plus à jour<br/>car le terme 6 > 5]
    end

Comparaison de mise à jour :

  1. Comparer le terme des dernières entrées
  2. Si les termes diffèrent, le journal avec le terme le plus élevé est plus à jour
  3. Si les termes sont identiques, le journal avec la longueur la plus longue est plus à jour

Délais d'Élection Randomisés

Raft utilise des délais d'élection randomisés pour empêcher les votes partagés :

timeline
    title Les Délais Randomisés Empêchent les Votes Partagés

    Node1 : Délai de 150ms
    Node2 : Délai de 300ms
    Node3 : Délai de 200ms

    Node1 : Délai ! Devient candidat
    Node1 : Gagne l'élection avant Node2/3 délai
    Node2 & Node3 : Reçoivent le battement de cœur, réinitialisent les délais

Sans randomisation : Tous les suiveurs atteignent le délai simultanément → tous deviennent candidats → vote partagé → aucun leader élu.

Avec randomisation : Un seul suiveur atteint le délai en premier → devient candidat → susceptible de gagner l'élection.


Structure d'Implémentation TypeScript

// Définitions de types pour Raft
type NodeState = 'follower' | 'candidate' | 'leader';

interface LogEntry {
  index: number;
  term: number;
  command: { key: string; value: any };
}

interface RaftNode {
  // État persistant
  currentTerm: number;
  votedFor: string | null;
  log: LogEntry[];

  // État volatil
  commitIndex: number;
  lastApplied: number;
  state: NodeState;

  // État volatil uniquement pour le leader
  nextIndex: number[];
  matchIndex: number[];
}

class RaftNodeImpl implements RaftNode {
  currentTerm: number = 0;
  votedFor: string | null = null;
  log: LogEntry[] = [];
  commitIndex: number = 0;
  lastApplied: number = 0;
  state: NodeState = 'follower';
  nextIndex: number[] = [];
  matchIndex: number[] = [];

  // Gérer le RPC RequestVote
  requestVote(args: RequestVoteArgs): RequestVoteReply {
    if (args.term > this.currentTerm) {
      this.currentTerm = args.term;
      this.state = 'follower';
      this.votedFor = null;
    }

    const logOk = this.isLogAtLeastAsUpToDate(args.lastLogIndex, args.lastLogTerm);
    const voteOk = (this.votedFor === null || this.votedFor === args.candidateId);

    if (args.term === this.currentTerm && voteOk && logOk) {
      this.votedFor = args.candidateId;
      return { term: this.currentTerm, voteGranted: true };
    }

    return { term: this.currentTerm, voteGranted: false };
  }

  // Gérer le RPC AppendEntries
  appendEntries(args: AppendEntriesArgs): AppendEntriesReply {
    if (args.term > this.currentTerm) {
      this.currentTerm = args.term;
      this.state = 'follower';
    }

    if (args.term !== this.currentTerm) {
      return { term: this.currentTerm, success: false };
    }

    // Vérifier si le journal a une entrée à prevLogIndex avec prevLogTerm
    if (this.log[args.prevLogIndex]?.term !== args.prevLogTerm) {
      return { term: this.currentTerm, success: false };
    }

    // Ajouter de nouvelles entrées
    for (const entry of args.entries) {
      this.log[entry.index] = entry;
    }

    // Mettre à jour l'index de validation
    if (args.leaderCommit > this.commitIndex) {
      this.commitIndex = Math.min(args.leaderCommit, this.log.length - 1);
    }

    return { term: this.currentTerm, success: true };
  }

  private isLogAtLeastAsUpToDate(lastLogIndex: number, lastLogTerm: number): boolean {
    const myLastEntry = this.log[this.log.length - 1];
    const myLastTerm = myLastEntry?.term ?? 0;
    const myLastIndex = this.log.length - 1;

    if (lastLogTerm !== myLastTerm) {
      return lastLogTerm > myLastTerm;
    }
    return lastLogIndex >= myLastIndex;
  }
}

Structure d'Implémentation Python

from dataclasses import dataclass, field
from typing import Optional, List
from enum import Enum

class NodeState(Enum):
    FOLLOWER = "follower"
    CANDIDATE = "candidate"
    LEADER = "leader"

@dataclass
class LogEntry:
    index: int
    term: int
    command: dict

@dataclass
class RequestVoteArgs:
    term: int
    candidate_id: str
    last_log_index: int
    last_log_term: int

@dataclass
class RequestVoteReply:
    term: int
    vote_granted: bool

@dataclass
class AppendEntriesArgs:
    term: int
    leader_id: str
    prev_log_index: int
    prev_log_term: int
    entries: List[LogEntry]
    leader_commit: int

@dataclass
class AppendEntriesReply:
    term: int
    success: bool

class RaftNode:
    def __init__(self, node_id: str, peers: List[str]):
        # État persistant
        self.current_term: int = 0
        self.voted_for: Optional[str] = None
        self.log: List[LogEntry] = []

        # État volatil
        self.commit_index: int = 0
        self.last_applied: int = 0
        self.state: NodeState = NodeState.FOLLOWER

        # État uniquement pour le leader
        self.next_index: dict[str, int] = {}
        self.match_index: dict[str, int] = {}

        self.node_id = node_id
        self.peers = peers

    def request_vote(self, args: RequestVoteArgs) -> RequestVoteReply:
        """Gérer le RPC RequestVote."""
        if args.term > self.current_term:
            self.current_term = args.term
            self.state = NodeState.FOLLOWER
            self.voted_for = None

        log_ok = self._is_log_at_least_as_up_to_date(
            args.last_log_index, args.last_log_term
        )
        vote_ok = (self.voted_for is None or self.voted_for == args.candidate_id)

        if args.term == self.current_term and vote_ok and log_ok:
            self.voted_for = args.candidate_id
            return RequestVoteReply(self.current_term, True)

        return RequestVoteReply(self.current_term, False)

    def append_entries(self, args: AppendEntriesArgs) -> AppendEntriesReply:
        """Gérer le RPC AppendEntries."""
        if args.term > self.current_term:
            self.current_term = args.term
            self.state = NodeState.FOLLOWER

        if args.term != self.current_term:
            return AppendEntriesReply(self.current_term, False)

        # Vérifier si le journal a une entrée à prev_log_index avec prev_log_term
        if len(self.log) <= args.prev_log_index:
            return AppendEntriesReply(self.current_term, False)

        if self.log[args.prev_log_index].term != args.prev_log_term:
            return AppendEntriesReply(self.current_term, False)

        # Ajouter de nouvelles entrées
        for entry in args.entries:
            if len(self.log) > entry.index:
                if self.log[entry.index].term != entry.term:
                    # Conflit : supprimer à partir de ce point
                    self.log = self.log[:entry.index]
            if len(self.log) <= entry.index:
                self.log.append(entry)

        # Mettre à jour l'index de validation
        if args.leader_commit > self.commit_index:
            self.commit_index = min(args.leader_commit, len(self.log) - 1)

        return AppendEntriesReply(self.current_term, True)

    def _is_log_at_least_as_up_to_date(self, last_index: int, last_term: int) -> bool:
        """Vérifier si le journal du candidat est au moins aussi à jour que le nôtre."""
        if not self.log:
            return True

        my_last_entry = self.log[-1]
        my_last_term = my_last_entry.term
        my_last_index = len(self.log) - 1

        if last_term != my_last_term:
            return last_term > my_last_term
        return last_index >= my_last_index

Résumé

Points Clés à Retenir

  1. Raft a été conçu pour la compréhension, séparant le consensus en sous-problèmes clairs
  2. Trois états de nœuds : Suiveur → Candidat → Leader
  3. Termes fournissent une horloge logique et empêchent les leaders obsolètes
  4. Deux RPCs principaux : RequestVote (élection) et AppendEntries (réplication + battement de cœur)
  5. Délais randomisés empêchent les votes partagés lors des élections
  6. Cinq propriétés de sécurité garantissent la correction : sécurité d'élection, ajout-seulement, correspondance de journal, complétude du leader et sécurité de la machine à états

Prochaine Session

Dans la prochaine session, nous plongerons dans l'Élection de Leader :

  • Comment les élections sont déclenchées
  • L'algorithme d'élection en détail
  • Gérer les votes partagés
  • Exemples d'élection de leader

Exercices

  1. Transitions d'États : Dessinez le diagramme de transition d'états pour un nœud qui commence comme suiveur, devient candidat, gagne l'élection comme leader, puis découvre un terme supérieur.

  2. Logique de Terme : Si un nœud reçoit un AppendEntries avec terme=7 mais son terme actuel est 9, que doit-il faire ?

  3. Comparaison de Journal : Comparez ces deux journaux et déterminez lequel est le plus à jour :

    • Journal A : dernier index=15, dernier terme=5
    • Journal B : dernier index=12, dernier terme=7
  4. Vote Partagé : Décrivez un scénario où un vote partagé pourrait se produire, et comment Raft empêche qu'il persiste.

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.

Élection de Leader Raft

Session 9, Partie 1 - 45 minutes

Objectifs d'Apprentissage

  • Comprendre comment Raft élit un leader démocratiquement
  • Implémenter le RPC RequestVote
  • Gérer les délais d'élection et les intervalles randomisés
  • Empêcher les votes partagés avec la sécurité d'élection
  • Construire un système d'élection de leader fonctionnel

Concept : Élection Démocratique de Leader

Dans le chapitre précédent, nous avons appris la philosophie de conception de Raft. Maintenant, plongeons dans le mécanisme d'élection de leader — le processus démocratique par lequel les nœuds s'accordent sur qui doit diriger.

Pourquoi avons-nous besoin d'un Leader ?

Sans Leader :
┌─────────┐     ┌─────────┐     ┌─────────┐
│ Nœud A  │     │ Nœud B  │     │ Nœud C  │
│         │     │         │     │         │
│ "Je     │     │ "Non,   │     │ "Les    │
│ suis    │     │ moi !   │     │ deux    │
│ leader!" │     │         │     │ tort !" │
└─────────┘     └─────────┘     └─────────┘
     Chaos !      Split brain !   Confusion !

Avec Élection de Leader Raft :
┌─────────┐     ┌─────────┐     ┌─────────┐
│ Nœud A  │     │ Nœud B  │     │ Nœud C  │
│         │     │         │     │         │
│ "Je     │     │ "Je     │     │ "Je vote │
│ vote    │---> │ vote    │---> │ pour    │
│ pour B"  │     │ pour B"  │     │ B"      │
└────┬────┘     └────┬────┘     └────┬────┘
     │               │               │
     └───────────────┴───────────────┘
                     │
                     ▼
              ┌──────────┐
              │ Nœud B   │
              │ = LEADER │
              └──────────┘

Aperçu Clé : Les nœuds votent les uns pour les autres. Le nœud avec la majorité des votes devient leader.


Transitions d'États Pendant l'Élection

Les nœuds Raft passent par trois états pendant l'élection de leader :

stateDiagram-v2
    [*] --> Suiveur: Démarrage

    Suiveur --> Candidat: Délai d'élection
    Suiveur --> Suiveur: Recevoir AppendEntries valide
    Suiveur --> Suiveur: Découvrir un terme supérieur

    Candidat --> Leader: Recevoir la majorité des votes
    Candidat --> Candidat: Vote partagé (délai)
    Candidat --> Suiveur: Découvrir un terme supérieur
    Candidat --> Suiveur: Recevoir AppendEntries valide

    Leader --> Suiveur: Découvrir un terme supérieur

    note right of Suiveur
        - Vote pour au plus un candidat par terme
        - Réinitialise le délai d'élection sur battement de cœur
    end note

    note right of Candidat
        - Incrémente le terme actuel
        - Vote pour lui-même
        - Envoie RequestVote à tous les nœuds
        - Le délai randomisé empêche l'interblocage
    end note

    note right of Leader
        - Envoie des battements de cœur (AppendEntries vides)
        - Gère les demandes clientes
        - Réplique les entrées de journal
    end note

L'Algorithme d'Élection Étape par Étape

Étape 1 : Délai du Suiveur

Lorsqu'un suiveur n'entend pas le leader dans le délai d'élection :

Temps ────────────────────────────────────────────────────────>

Nœud A : [en attente...] [en attente...] ⏱️ DÉLAI ! → Devenir Candidat
Nœud B : [en attente...] [en attente...] [en attente...]
Nœud C : [en attente...] [en attente...] [en attente...]

Étape 2 : Devenir Candidat

Le nœud passe à l'état de candidat :

sequenceDiagram
    participant C as Candidat (Nœud A)
    participant A as Tous les Nœuds

    C->>C: Incrémenter le terme (ex: terme = 4)
    C->>C: Voter pour soi-même
    C->>A: Envoyer RequestVote(terme=4) à tous

    Note over C: Attendre les votes...

    par Chaque suiveur traite RequestVote
        A->>A: Si terme < termeActuel : rejeter
        A->>A: Si votéPour != null : rejeter
        A->>A: Si le journal du candidat est à jour : accorder le vote
    end

    A-->>C: Envoyer la réponse de vote

    alt Majorité de votes reçue
        C->>C: Devenir LEADER
    else Vote partagé
        C->>C: Attendre le délai, puis réessayer
    end

Étape 3 : RPC RequestVote

Le RequestVote RPC est le bulletin de vote dans l'élection de Raft :

graph LR
    subgraph RequestVote RPC
        C[term] --> D["Terme du candidat"]
        E[candidateId] --> F["Nœud demandant le vote"]
        G[lastLogIndex] --> H["Index de la dernière entrée de journal du candidat"]
        I[lastLogTerm] --> J["Terme de la dernière entrée de journal du candidat"]
    end

    subgraph Réponse
        K[term] --> L["Terme actuel (pour que le candidat mette à jour)"]
        M[voteGranted] --> N["vrai si le suiveur a voté"]
    end

Règle de Vote : Un suiveur accorde le vote si :

  1. Le terme du candidat > termeActuel du suiveur, OU
  2. Les termes sont égaux ET le suiveur n'a pas encore voté ET le journal du candidat est au moins à jour

Délais d'Élection Randomisés

Le Problème du Vote Partagé

Sans randomisation, les élections simultanées causent des interblocages :

Mauvais : Les délais fixes causent des votes partagés répétés
Nœud A : délai à T=100 → Candidat, obtient 1 vote
Nœud B : délai à T=100 → Candidat, obtient 1 vote
Nœud C : délai à T=100 → Candidat, obtient 1 vote

Résultat : Personne ne gagne ! Délai d'élection...
La même chose se répète pour toujours !

Solution : Intervalles Randomisés

Chaque nœud choisit un délai aléatoire dans une plage :

gantt
    title Délais d'Élection (Randomisés : 150-300ms)
    dateFormat X
    axisFormat %L

    Nœud A :a1, 0, 180
    Nœud B :b1, 0, 220
    Nœud C :c1, 0, 160

    Nœud A devient Candidat :milestone, m1, 180, 0s
    Nœud C devient Candidat :milestone, m2, 160, 0s

Le Nœud C atteint le délai en premier et commence l'élection. Les Nœuds A et B réinitialisent leurs délais lorsqu'ils reçoivent RequestVote, permettant au Nœud C de rassembler les votes.

Analyse de Probabilité : Pour un cluster de N nœuds avec une plage de délai [T, 2T] :

  • Probabilité de délai simultané : ~1/N
  • Avec 5 nœuds et une plage de 150-300ms : P < 5%

Implémentation TypeScript

Construisons un système d'élection de leader Raft fonctionnel :

Types Fondamentaux

// types/raft.ts

export type NodeState = 'follower' | 'candidate' | 'leader';

export interface LogEntry {
  index: number;
  term: number;
  command: unknown;
}

export interface RaftNodeConfig {
  id: string;
  peers: string[];  // Liste des IDs de nœuds pairs
  electionTimeoutMin: number;  // Délai minimum en ms
  electionTimeoutMax: number;  // Délai maximum en ms
}

export interface RequestVoteArgs {
  term: number;
  candidateId: string;
  lastLogIndex: number;
  lastLogTerm: number;
}

export interface RequestVoteReply {
  term: number;
  voteGranted: boolean;
}

export interface AppendEntriesArgs {
  term: number;
  leaderId: string;
  prevLogIndex: number;
  prevLogTerm: number;
  entries: LogEntry[];
  leaderCommit: number;
}

export interface AppendEntriesReply {
  term: number;
  success: boolean;
}

Implémentation du Nœud Raft

// raft-node.ts

import { RaftNodeConfig, NodeState, LogEntry, RequestVoteArgs, RequestVoteReply } from './types';

export class RaftNode {
  private state: NodeState = 'follower';
  private currentTerm: number = 0;
  private votedFor: string | null = null;
  private log: LogEntry[] = [];

  // Délai d'élection
  private electionTimer: NodeJS.Timeout | null = null;
  private lastHeartbeat: number = Date.now();

  // État uniquement pour le leader
  private leaderId: string | null = null;

  constructor(private config: RaftNodeConfig) {
    this.startElectionTimer();
  }

  // ========== API Publique ==========

  getState(): NodeState {
    return this.state;
  }

  getCurrentTerm(): number {
    return this.currentTerm;
  }

  getLeader(): string | null {
    return this.leaderId;
  }

  // ========== Gestionnaires RPC ==========

  /**
   * Invoqué par les candidats pour rassembler les votes
   */
  requestVote(args: RequestVoteArgs): RequestVoteReply {
    const reply: RequestVoteReply = {
      term: this.currentTerm,
      voteGranted: false
    };

    // Règle 1 : Si le terme du candidat est inférieur, rejeter
    if (args.term < this.currentTerm) {
      return reply;
    }

    // Règle 2 : Si le terme du candidat est supérieur, mettre à jour et devenir suiveur
    if (args.term > this.currentTerm) {
      this.becomeFollower(args.term);
      reply.term = args.term;
    }

    // Règle 3 : Si nous avons déjà voté pour quelqu'un d'autre ce terme, rejeter
    if (this.votedFor !== null && this.votedFor !== args.candidateId) {
      return reply;
    }

    // Règle 4 : Vérifier si le journal du candidat est au moins à jour que le nôtre
    const lastEntry = this.log.length > 0 ? this.log[this.log.length - 1] : null;
    const lastLogIndex = lastEntry ? lastEntry.index : 0;
    const lastLogTerm = lastEntry ? lastEntry.term : 0;

    const logIsUpToDate =
      (args.lastLogTerm > lastLogTerm) ||
      (args.lastLogTerm === lastLogTerm && args.lastLogIndex >= lastLogIndex);

    if (!logIsUpToDate) {
      return reply;
    }

    // Accorder le vote
    this.votedFor = args.candidateId;
    reply.voteGranted = true;
    this.resetElectionTimer();

    console.log(`Nœud ${this.config.id} a voté pour ${args.candidateId} au terme ${args.term}`);
    return reply;
  }

  /**
   * Invoqué par le leader pour affirmer l'autorité (battement de cœur ou réplication de journal)
   */
  receiveHeartbeat(term: number, leaderId: string): void {
    if (term >= this.currentTerm) {
      if (term > this.currentTerm) {
        this.becomeFollower(term);
      }
      this.leaderId = leaderId;
      this.resetElectionTimer();
    }
  }

  // ========== Transitions d'États ==========

  private becomeFollower(term: number): void {
    this.state = 'follower';
    this.currentTerm = term;
    this.votedFor = null;
    this.leaderId = null;
    this.resetElectionTimer();
    console.log(`Nœud ${this.config.id} est devenu suiveur au terme ${term}`);
  }

  private becomeCandidate(): void {
    this.state = 'candidate';
    this.currentTerm += 1;
    this.votedFor = this.config.id;
    this.leaderId = null;

    console.log(`Nœud ${this.config.id} est devenu candidat au terme ${this.currentTerm}`);

    // Démarrer l'élection
    this.startElection();
  }

  private becomeLeader(): void {
    this.state = 'leader';
    this.leaderId = this.config.id;

    console.log(`Nœud ${this.config.id} est devenu LEADER au terme ${this.currentTerm}`);

    // Commencer à envoyer des battements de cœur
    this.startHeartbeats();
  }

  // ========== Logique d'Élection ==========

  private startElectionTimer(): void {
    if (this.electionTimer) {
      clearTimeout(this.electionTimer);
    }

    const timeout = this.getRandomElectionTimeout();

    this.electionTimer = setTimeout(() => {
      // Ne transitionner que si nous n'avons pas entendu d'un leader
      if (this.state === 'follower') {
        console.log(`Nœud ${this.config.id} délai d'élection`);
        this.becomeCandidate();
      }
    }, timeout);
  }

  private resetElectionTimer(): void {
    this.startElectionTimer();
  }

  private getRandomElectionTimeout(): number {
    const { electionTimeoutMin, electionTimeoutMax } = this.config;
    return Math.floor(
      Math.random() * (electionTimeoutMax - electionTimeoutMin + 1)
    ) + electionTimeoutMin;
  }

  private async startElection(): Promise<void> {
    const args: RequestVoteArgs = {
      term: this.currentTerm,
      candidateId: this.config.id,
      lastLogIndex: this.log.length > 0 ? this.log[this.log.length - 1].index : 0,
      lastLogTerm: this.log.length > 0 ? this.log[this.log.length - 1].term : 0
    };

    let votesReceived = 1; // Vote pour soi-même
    const majority = Math.floor(this.config.peers.length / 2) + 1;

    // Envoyer RequestVote à tous les pairs
    const promises = this.config.peers.map(peerId =>
      this.sendRequestVote(peerId, args)
    );

    const responses = await Promise.allSettled(promises);

    // Compter les votes
    for (const result of responses) {
      if (result.status === 'fulfilled' && result.value.voteGranted) {
        votesReceived++;
      }
    }

    // Vérifier si nous avons gagné
    if (votesReceived >= majority && this.state === 'candidate') {
      this.becomeLeader();
    }
  }

  // ========== Simulation Réseau ==========

  private async sendRequestVote(
    peerId: string,
    args: RequestVoteArgs
  ): Promise<RequestVoteReply> {
    // Dans une implémentation réelle, ce serait un appel HTTP/gRPC
    // Pour cet exemple, nous simulons en appelant directement
    // Dans l'exemple complet ci-dessous, nous utiliserons HTTP réel

    return {
      term: 0,
      voteGranted: false
    };
  }

  private startHeartbeats(): void {
    // Le leader envoie des battements de cœur périodiques
    // Implémentation dans l'exemple complet
  }

  stop(): void {
    if (this.electionTimer) {
      clearTimeout(this.electionTimer);
    }
  }
}

Serveur HTTP avec Raft

// server.ts

import express, { Request, Response } from 'express';
import { RaftNode } from './raft-node';
import { RequestVoteArgs, RequestVoteReply } from './types';

export class RaftServer {
  private app: express.Application;
  private node: RaftNode;
  private server: any;

  constructor(
    private nodeId: string,
    private port: number,
    peers: string[]
  ) {
    this.app = express();
    this.app.use(express.json());

    this.node = new RaftNode({
      id: nodeId,
      peers: peers,
      electionTimeoutMin: 150,
      electionTimeoutMax: 300
    });

    this.setupRoutes();
  }

  private setupRoutes(): void {
    // Point de terminaison RPC RequestVote
    this.app.post('/raft/request-vote', (req: Request, res: Response) => {
      const args: RequestVoteArgs = req.body;
      const reply: RequestVoteReply = this.node.requestVote(args);
      res.json(reply);
    });

    // Point de terminaison de battement de cœur
    this.app.post('/raft/heartbeat', (req: Request, res: Response) => {
      const { term, leaderId } = req.body;
      this.node.receiveHeartbeat(term, leaderId);
      res.json({ success: true });
    });

    // Point de terminaison d'état
    this.app.get('/status', (req: Request, res: Response) => {
      res.json({
        id: this.nodeId,
        state: this.node.getState(),
        term: this.node.getCurrentTerm(),
        leader: this.node.getLeader()
      });
    });
  }

  async start(): Promise<void> {
    this.server = this.app.listen(this.port, () => {
      console.log(`Nœud ${this.nodeId} écoute sur le port ${this.port}`);
    });
  }

  stop(): void {
    this.node.stop();
    if (this.server) {
      this.server.close();
    }
  }

  getNode(): RaftNode {
    return this.node;
  }
}

Implémentation Python

La même logique en Python :

# raft_node.py

import asyncio
import random
from dataclasses import dataclass
from enum import Enum
from typing import Optional, List
from datetime import datetime, timedelta

class NodeState(Enum):
    FOLLOWER = "follower"
    CANDIDATE = "candidate"
    LEADER = "leader"

@dataclass
class LogEntry:
    index: int
    term: int
    command: dict

@dataclass
class RequestVoteArgs:
    term: int
    candidate_id: str
    last_log_index: int
    last_log_term: int

@dataclass
class RequestVoteReply:
    term: int
    vote_granted: bool

class RaftNode:
    def __init__(self, node_id: str, peers: List[str],
                 election_timeout_min: int = 150,
                 election_timeout_max: int = 300):
        self.node_id = node_id
        self.peers = peers

        # État persistant
        self.current_term = 0
        self.voted_for: Optional[str] = None
        self.log: List[LogEntry] = []

        # État volatil
        self.state = NodeState.FOLLOWER
        self.leader_id: Optional[str] = None

        # Délai d'élection
        self.election_timeout_min = election_timeout_min
        self.election_timeout_max = election_timeout_max
        self.election_task: Optional[asyncio.Task] = None
        self.heartbeat_task: Optional[asyncio.Task] = None

        # Démarrer le timer d'élection
        self.start_election_timer()

    async def request_vote(self, args: RequestVoteArgs) -> RequestVoteReply:
        """Gérer le RPC RequestVote du candidat"""
        reply = RequestVoteReply(
            term=self.current_term,
            vote_granted=False
        )

        # Règle 1 : Rejeter si le terme du candidat est inférieur
        if args.term < self.current_term:
            return reply

        # Règle 2 : Mettre à jour au terme supérieur et devenir suiveur
        if args.term > self.current_term:
            await self.become_follower(args.term)
            reply.term = args.term

        # Règle 3 : Rejeter si déjà voté pour un autre candidat
        if self.voted_for is not None and self.voted_for != args.candidate_id:
            return reply

        # Règle 4 : Vérifier si le journal du candidat est à jour
        last_entry = self.log[-1] if self.log else None
        last_log_index = last_entry.index if last_entry else 0
        last_log_term = last_entry.term if last_entry else 0

        log_is_up_to_date = (
            args.last_log_term > last_log_term or
            (args.last_log_term == last_log_term and
             args.last_log_index >= last_log_index)
        )

        if not log_is_up_to_date:
            return reply

        # Accorder le vote
        self.voted_for = args.candidate_id
        reply.vote_granted = True
        self.reset_election_timer()

        print(f"Nœud {self.node_id} a voté pour {args.candidate_id} au terme {args.term}")
        return reply

    async def receive_heartbeat(self, term: int, leader_id: str):
        """Gérer le battement de cœur du leader"""
        if term >= self.current_term:
            if term > self.current_term:
                await self.become_follower(term)
            self.leader_id = leader_id
            self.reset_election_timer()

    async def become_follower(self, term: int):
        """Transitionner vers l'état de suiveur"""
        self.state = NodeState.FOLLOWER
        self.current_term = term
        self.voted_for = None
        self.leader_id = None

        # Annuler la tâche de battement de cœur si en cours
        if self.heartbeat_task:
            self.heartbeat_task.cancel()
            self.heartbeat_task = None

        self.reset_election_timer()
        print(f"Nœud {self.node_id} est devenu suiveur au terme {term}")

    async def become_candidate(self):
        """Transitionner vers l'état de candidat et démarrer l'élection"""
        self.state = NodeState.CANDIDATE
        self.current_term += 1
        self.voted_for = self.node_id
        self.leader_id = None

        print(f"Nœud {self.node_id} est devenu candidat au terme {self.current_term}")
        await self.start_election()

    async def become_leader(self):
        """Transitionner vers l'état de leader"""
        self.state = NodeState.LEADER
        self.leader_id = self.node_id

        print(f"Nœud {self.node_id} est devenu LEADER au terme {self.current_term}")
        self.start_heartbeats()

    def start_election_timer(self):
        """Démarrer ou réinitialiser le timer de délai d'élection"""
        if self.election_task:
            self.election_task.cancel()

        timeout = self.get_random_election_timeout()
        self.election_task = asyncio.create_task(self.election_timeout(timeout))

    def reset_election_timer(self):
        """Réinitialiser le timer de délai d'élection"""
        self.start_election_timer()

    def get_random_election_timeout(self) -> int:
        """Obtenir un délai aléatoire dans la plage configurée"""
        return random.randint(
            self.election_timeout_min,
            self.election_timeout_max
        )

    async def election_timeout(self, timeout_ms: int):
        """Attendre le délai, puis démarrer l'élection si toujours suiveur"""
        try:
            await asyncio.sleep(timeout_ms / 1000)
            if self.state == NodeState.FOLLOWER:
                print(f"Nœud {self.node_id} délai d'élection")
                await self.become_candidate()
        except asyncio.CancelledError:
            pass  # Le timer a été réinitialisé

    async def start_election(self):
        """Démarrer l'élection de leader en envoyant RequestVote à tous les pairs"""
        args = RequestVoteArgs(
            term=self.current_term,
            candidate_id=self.node_id,
            last_log_index=self.log[-1].index if self.log else 0,
            last_log_term=self.log[-1].term if self.log else 0
        )

        votes_received = 1  # Vote pour soi-même
        majority = len(self.peers) // 2 + 1

        # Envoyer RequestVote à tous les pairs simultanément
        tasks = [
            self.send_request_vote(peer_id, args)
            for peer_id in self.peers
        ]

        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Compter les votes
        for result in results:
            if isinstance(result, RequestVoteReply) and result.vote_granted:
                votes_received += 1

        # Vérifier si nous avons gagné l'élection
        if votes_received >= majority and self.state == NodeState.CANDIDATE:
            await self.become_leader()

    async def send_request_vote(self, peer_id: str, args: RequestVoteArgs) -> RequestVoteReply:
        """Envoyer le RPC RequestVote au pair (simulé)"""
        # Dans une implémentation réelle, utiliser HTTP/aiohttp
        # Pour cet exemple, retourner une réponse simulée
        return RequestVoteReply(term=0, vote_granted=False)

    def start_heartbeats(self):
        """Le leader envoie des battements de cœur périodiques"""
        if self.heartbeat_task:
            self.heartbeat_task.cancel()

        self.heartbeat_task = asyncio.create_task(self.send_heartbeats())

    async def send_heartbeats(self):
        """Envoyer des AppendEntries vides (battements de cœur) à tous les suiveurs"""
        while self.state == NodeState.LEADER:
            for peer_id in self.peers:
                # Dans une implémentation réelle, envoyer HTTP POST
                await asyncio.sleep(0.05)  # Intervalle de battement de cœur : 50ms

    def stop(self):
        """Arrêter le nœud"""
        if self.election_task:
            self.election_task.cancel()
        if self.heartbeat_task:
            self.heartbeat_task.cancel()

Serveur Flask avec Raft

# server.py

from flask import Flask, request, jsonify
from raft_node import RaftNode, RequestVoteArgs
import asyncio

app = Flask(__name__)

class RaftServer:
    def __init__(self, node_id: str, port: int, peers: list):
        self.node_id = node_id
        self.port = port
        self.node = RaftNode(node_id, peers)
        self.app = app
        self.setup_routes()

    def setup_routes(self):
        @self.app.route('/raft/request-vote', methods=['POST'])
        def request_vote():
            args = RequestVoteArgs(**request.json)
            reply = asyncio.run(self.node.request_vote(args))
            return jsonify({
                'term': reply.term,
                'voteGranted': reply.vote_granted
            })

        @self.app.route('/raft/heartbeat', methods=['POST'])
        def heartbeat():
            data = request.json
            asyncio.run(self.node.receive_heartbeat(
                data['term'], data['leaderId']
            ))
            return jsonify({'success': True})

        @self.app.route('/status', methods=['GET'])
        def status():
            return jsonify({
                'id': self.node_id,
                'state': self.node.state.value,
                'term': self.node.current_term,
                'leader': self.node.leader_id
            })

    def run(self):
        self.app.run(port=self.port, debug=False)

Configuration Docker Compose

Déployons un cluster Raft à 3 nœuds :

# docker-compose.yml

version: '3.8'

services:
  node1:
    build:
      context: ./examples/04-consensus
      dockerfile: Dockerfile.typescript
    container_name: raft-node1
    environment:
      - NODE_ID=node1
      - PORT=3001
      - PEERS=node2:3002,node3:3003
    ports:
      - "3001:3001"
    networks:
      - raft-network

  node2:
    build:
      context: ./examples/04-consensus
      dockerfile: Dockerfile.typescript
    container_name: raft-node2
    environment:
      - NODE_ID=node2
      - PORT=3002
      - PEERS=node1:3001,node3:3003
    ports:
      - "3002:3002"
    networks:
      - raft-network

  node3:
    build:
      context: ./examples/04-consensus
      dockerfile: Dockerfile.typescript
    container_name: raft-node3
    environment:
      - NODE_ID=node3
      - PORT=3003
      - PEERS=node1:3001,node2:3002
    ports:
      - "3003:3003"
    networks:
      - raft-network

networks:
  raft-network:
    driver: bridge

Exécution de l'Exemple

Version TypeScript

# 1. Construire et démarrer le cluster
cd distributed-systems-course/examples/04-consensus
docker-compose up

# 2. Observer l'élection se produire dans les journaux
# Vous verrez les nœuds transitionner de suiveur → candidat → leader

# 3. Vérifier l'état de chaque nœud
curl http://localhost:3001/status
curl http://localhost:3002/status
curl http://localhost:3003/status

# 4. Tuer le leader et observer la réélection
docker-compose stop node1  # Si node1 était leader
# Observer les journaux pour voir un nouveau leader élu !

# 5. Nettoyer
docker-compose down

Version Python

# 1. Construire et démarrer le cluster
cd distributed-systems-course/examples/04-consensus
docker-compose -f docker-compose.python.yml up

# 2-5. Identique à ci-dessus, utilisant les ports 4001-4003 pour les nœuds Python

Exercices

Exercice 1 : Observer la Sécurité d'Élection

Exécutez le cluster et répondez à ces questions :

  1. Combien de temps faut-il pour qu'un leader soit élu ?
  2. Que se passe-t-il si vous démarrez les nœuds à des moments différents ?
  3. Pouvez-vous observer un scénario de vote partagé ? (Indice : causez une partition réseau)

Exercice 2 : Implémenter le Pré-Vote

Le pré-vote est une optimisation qui empêche de perturber un leader stable :

  1. Renseignez-vous sur le mécanisme de pré-vote
  2. Modifiez le gestionnaire RequestVote pour vérifier d'abord si le leader est en vie
  3. Testez que le pré-vote empêche les élections inutiles

Exercice 3 : Réglage du Délai d'Élection

Expérimentez avec différentes plages de délai :

  1. Essayez 50-100ms : Que se passe-t-il ? (Indice : trop d'élections)
  2. Essayez 500-1000ms : Que se passe-t-il ? (Indice : basculement de leader lent)
  3. Trouvez la plage optimale pour un cluster à 3 nœuds

Exercice 4 : Simulation de Partition Réseau

Simulez une partition réseau :

  1. Démarrez le cluster et attendez l'élection du leader
  2. Isolez node1 du réseau (en utilisant l'isolement du réseau Docker)
  3. Observez : Est-ce que node1 pense toujours être leader ?
  4. Reconnectez : Est-ce que le cluster récupère correctement ?

Résumé

Dans ce chapitre, vous avez appris :

  • Pourquoi l'élection de leader est importante : Empêche le split-brain et la confusion
  • Le processus démocratique de Raft : Les nœuds votent les uns pour les autres
  • Transitions d'états : Suiveur → Candidat → Leader
  • RPC RequestVote : Le bulletin de vote des élections Raft
  • Délais randomisés : Empêchent les votes partagés et les interblocages
  • Sécurité d'élection : Au plus un leader par terme

Prochain Chapitre : Réplication de Journal — Une fois que nous avons un leader, comment répliquons-nous en toute sécurité les données à travers le cluster ?

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.

Réplication de Journal

Session 10, Partie 1 - 30 minutes

Objectifs d'Apprentissage

  • Comprendre comment Raft réplique les journaux à travers les nœuds
  • Apprendre la propriété de correspondance de journal qui assure la cohérence
  • Implémenter le RPC AppendEntries
  • Gérer les conflits de cohérence de journal
  • Comprendre l'index de validation et l'application de la machine à états

Concept : Garder Tout le Monde Synchronisé

Une fois qu'un leader est élu, il doit répliquer les commandes clientes à tous les suiveurs. C'est la phase de réplication de journal de Raft.

Le Défi

Le Client envoie "SET x = 5" au Leader

┌──────────┐         ┌──────────┐         ┌──────────┐
│  Leader  │         │ Suiveur  │         │ Suiveur  │
│          │         │    A     │         │    B     │
└────┬─────┘         └──────────┘         └──────────┘
     │
     │ Comment nous assurer que TOUS les nœuds
     │ ont le MÊME journal de commandes ?
     │
     │ Que se passe-t-il si le réseau échoue ?
     │ Que se passe-t-il si le suiveur plante ?
     ▼

┌─────────────────────────────────────────┐
│     Protocole de Réplication de Journal │
└─────────────────────────────────────────┘

Structure du Journal

Chaque nœud maintient un journal de commandes. Une entrée de journal contient :

interface LogEntry {
  index: number;      // Position dans le journal (commence à 1)
  term: number;       // Terme quand l'entrée a été reçue
  command: string;    // La commande actuelle (ex: "SET x = 5")
}
@dataclass
class LogEntry:
    index: int       # Position dans le journal (commence à 1)
    term: int        # Terme quand l'entrée a été reçue
    command: str     # La commande actuelle (ex: "SET x = 5")

Représentation Visuelle du Journal

Nœud 1 (Leader)              Nœud 2 (Suiveur)           Nœud 3 (Suiveur)
┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│ Index │ Terme │ Cmd│         │ Index │ Terme │ Cmd│         │ Index │ Terme │ Cmd│
├───────┼──────┼────┤         ├───────┼──────┼────┤         ├───────┼──────┼────┤
│   1   │  1   │SET │         │   1   │  1   │SET │         │   1   │  1   │SET │
│   2   │  2   │SET │         │   2   │  2   │SET │         │   2   │  2   │SET │
│   3   │  2   │SET │         │   3   │  2   │SET │         │       │      │    │
│   4   │  2   │SET │         │       │      │    │         │       │      │    │
└───────┴──────┴────┘         └───────┴──────┴────┘         └───────┴──────┴────┘

La Propriété de Correspondance de Journal

C'est la garantie de sécurité clé de Raft. Si deux journaux contiennent une entrée avec le même index et terme, alors toutes les entrées précédentes sont identiques et dans le même ordre.

         Propriété de Correspondance de Journal
┌────────────────────────────────────────────────────────┐
│                                                        │
│   Si journaux[i].terme == journaux[j].terme ET        │
│   journaux[i].index == journaux[j].index              │
│                                                        │
│   ALORS :                                               │
│   journaux[k] == journaux[k] pour tout k < i          │
│                                                        │
└────────────────────────────────────────────────────────┘

Exemple :

Nœud A : [1,1] [2,1] [3,2] [4,2] [5,2]
              │
Nœud B : [1,1] [2,1] [3,2] [4,2] [5,3] [6,3]
              │
              └─ Même index 3, terme 2
                  Par conséquent les entrées 1-2 sont IDENTIQUES

Cette propriété permet à Raft de détecter et corriger efficacement les incohérences.


RPC AppendEntries

Le leader utilise AppendEntries pour répliquer les entrées de journal et aussi comme un battement de cœur.

Spécification RPC

interface AppendEntriesRequest {
  term: number;           // Terme du leader
  leaderId: string;       // Pour que le suiveur puisse rediriger les clients
  prevLogIndex: number;   // Index de l'entrée de journal précédant immédiatement les nouvelles
  prevLogTerm: number;    // Terme de l'entrée prevLogIndex
  entries: LogEntry[];    // Entrées de journal à stocker (vide pour battement de cœur)
  leaderCommit: number;   // Index de validation du leader
}

interface AppendEntriesResponse {
  term: number;           // Terme actuel, pour que le leader se mette à jour
  success: boolean;       // Vrai si le suiveur avait l'entrée correspondant à prevLogIndex/terme
}
@dataclass
class AppendEntriesRequest:
    term: int              # Terme du leader
    leader_id: str         # Pour que le suiveur puisse rediriger les clients
    prev_log_index: int    # Index de l'entrée de journal précédant immédiatement les nouvelles
    prev_log_term: int     # Terme de l'entrée prevLogIndex
    entries: List[LogEntry]  # Entrées de journal à stocker (vide pour battement de cœur)
    leader_commit: int     # Index de validation du leader

@dataclass
class AppendEntriesResponse:
    term: int              # Terme actuel, pour que le leader se mette à jour
    success: bool          # Vrai si le suiveur avait l'entrée correspondant à prevLogIndex/terme

Flux de Réplication de Journal

sequenceDiagram
    participant C as Client
    participant L as Leader
    participant F1 as Suiveur 1
    participant F2 as Suiveur 2
    participant F3 as Suiveur 3

    C->>L: SET x = 5
    L->>L: Ajouter au journal (non validé)
    L->>F1: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)
    L->>F2: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)
    L->>F3: AppendEntries(entries=[SET x=5], prevLogIndex=2, prevLogTerm=1)

    F1->>F1: Ajouter au journal, répondre succès
    F2->>F2: Ajouter au journal, répondre succès
    F3->>F3: Ajouter au journal, répondre succès

    Note over L: Majorité reçue (2/3)
    L->>L: Index de validation = 3
    L->>L: Appliquer à la machine à états : x = 5
    L->>C: Retourner succès (x = 5)

    L->>F1: AppendEntries(entries=[], leaderCommit=3)
    L->>F2: AppendEntries(entries=[], leaderCommit=3)
    L->>F3: AppendEntries(entries=[], leaderCommit=3)

    F1->>F1: Appliquer les entrées validées
    F2->>F2: Appliquer les entrées validées
    F3->>F3: Appliquer les entrées validées

Gestion des Conflits de Cohérence

Lorsque le journal d'un suiveur entre en conflit avec celui du leader, le leader le résout :

graph TD
    A[Leader envoie AppendEntries] --> B{Suiveur vérifie<br/>prevLogIndex/terme}
    B -->|Correspondance trouvée| C[Ajouter de nouvelles entrées<br/>Retourner success=true]
    B -->|Pas de correspondance| D[Retourner success=false]

    D --> E[Leader décrémente<br/>nextIndex pour le suiveur]
    E --> F{Réessayer avec<br/>une position de journal antérieure ?}

    F -->|Oui| A
    F -->|Pas de correspondance à l'index 0| G[Ajouter le journal entier<br/>du leader]

    C --> H[Suiveur met à jour<br/>l'index de validation si nécessaire]
    H --> I[Appliquer les entrées validées<br/>à la machine à états]

Exemple de Conflit

Avant Résolution de Conflit :

Leader :  [1,1] [2,2] [3,2]
Suiveur :[1,1] [2,1] [3,1] [4,3]  ← Divergence à l'index 2 !

Étape 1 : Le leader envoie AppendEntries(prevLogIndex=2, prevLogTerm=2)
        Suiveur : Pas de correspondance ! (a le terme 1, pas 2) → Retourner success=false

Étape 2 : Le leader décrémente nextIndex, envoie AppendEntries(prevLogIndex=1, prevLogTerm=1)
        Suiveur : Correspondance ! → Retourner success=true

Étape 3 : Le leader envoie les entrées à partir de l'index 2
        Le suiveur écrase [2,1] [3,1] [4,3] avec [2,2] [3,2]

Après Résolution de Conflit :

Leader :  [1,1] [2,2] [3,2]
Suiveur :[1,1] [2,2] [3,2]  ← Maintenant cohérent !

Index de Validation

L'index de validation suit quelles entrées de journal sont validées (durables et sûres à appliquer).

let commitIndex = 0;  // Index de l'entrée validée la plus élevée

// Règle du leader : Une entrée du terme actuel est validée
// une fois stockée sur une majorité de serveurs
function updateCommitIndex(): void {
  const N = this.log.length;

  // Trouver le plus grand N tel que :
  // 1. Une majorité de nœuds ont des entrées de journal jusqu'à N
  // 2. log[N].term == currentTerm (règle de sécurité !)
  for (let i = N; i > this.commitIndex; i--) {
    if (this.log[i - 1].term === this.currentTerm && this.isMajorityReplicated(i)) {
      this.commitIndex = i;
      break;
    }
  }
}
commit_index: int = 0  # Index de l'entrée validée la plus élevée

# Règle du leader : Une entrée du terme actuel est validée
# une fois stockée sur une majorité de serveurs
def update_commit_index(self) -> None:
    N = len(self.log)

    # Trouver le plus grand N tel que :
    # 1. Une majorité de nœuds ont des entrées de journal jusqu'à N
    # 2. log[N].term == currentTerm (règle de sécurité !)
    for i in range(N, self.commit_index, -1):
        if self.log[i - 1].term == self.current_term and self.is_majority_replicated(i):
            self.commit_index = i
            break

Règle de Sécurité : Valider Seulement les Entrées du Terme Actuel

graph LR
    A[Entrée du<br/>terme précédent] -->|Peut être<br/>validée| B[Quand une entrée du<br/>terme actuel existe]
    C[Entrée du<br/>terme actuel] -->|Peut être<br/>validée| D[Quand répliquée<br/>à la majorité]

    B --> E[Appliquée à<br/>la machine à états]
    D --> E

    style B fill:#f99
    style D fill:#9f9

Pourquoi ? Empêche un leader de valider des entrées non validées de termes précédents qui pourraient être écrasées.


Implémentation TypeScript

Étendons notre implémentation Raft avec la réplication de journal :

// types.ts
export interface LogEntry {
  index: number;
  term: number;
  command: string;
}

export interface AppendEntriesRequest {
  term: number;
  leaderId: string;
  prevLogIndex: number;
  prevLogTerm: number;
  entries: LogEntry[];
  leaderCommit: number;
}

export interface AppendEntriesResponse {
  term: number;
  success: boolean;
}
// raft-node.ts
export class RaftNode {
  private log: LogEntry[] = [];
  private commitIndex = 0;
  private lastApplied = 0;

  // Pour chaque suiveur, suivre le prochain index de journal à envoyer
  private nextIndex: Map<string, number> = new Map();
  private matchIndex: Map<string, number> = new Map();

  // ... (code précédent de l'élection de leader)

  /**
   * Gérer le RPC AppendEntries du leader
   */
  handleAppendEntries(req: AppendEntriesRequest): AppendEntriesResponse {
    // Répondre faux si term < currentTerm
    if (req.term < this.currentTerm) {
      return { term: this.currentTerm, success: false };
    }

    // Mettre à jour le terme actuel si nécessaire
    if (req.term > this.currentTerm) {
      this.currentTerm = req.term;
      this.state = NodeState.Follower;
      this.votedFor = null;
    }

    // Réinitialiser le délai d'élection
    this.resetElectionTimeout();

    // Vérifier la cohérence du journal
    if (req.prevLogIndex > 0) {
      if (this.log.length < req.prevLogIndex) {
        return { term: this.currentTerm, success: false };
      }

      const prevEntry = this.log[req.prevLogIndex - 1];
      if (prevEntry.term !== req.prevLogTerm) {
        return { term: this.currentTerm, success: false };
      }
    }

    // Ajouter de nouvelles entrées
    if (req.entries.length > 0) {
      // Trouver la première entrée en conflit
      let insertIndex = req.prevLogIndex;
      for (const entry of req.entries) {
        if (insertIndex < this.log.length) {
          const existing = this.log[insertIndex];
          if (existing.index === entry.index && existing.term === entry.term) {
            // Correspond déjà, sauter
            insertIndex++;
            continue;
          }
          // Conflit ! Supprimer à partir d'ici et ajouter
          this.log = this.log.slice(0, insertIndex);
        }
        this.log.push(entry);
        insertIndex++;
      }
    }

    // Mettre à jour l'index de validation
    if (req.leaderCommit > this.commitIndex) {
      this.commitIndex = Math.min(req.leaderCommit, this.log.length);
      this.applyCommittedEntries();
    }

    return { term: this.currentTerm, success: true };
  }

  /**
   * Appliquer les entrées validées à la machine à états
   */
  private applyCommittedEntries(): void {
    while (this.lastApplied < this.commitIndex) {
      this.lastApplied++;
      const entry = this.log[this.lastApplied - 1];
      this.stateMachine.apply(entry);
      console.log(`Nœud ${this.nodeId} appliqué : ${entry.command}`);
    }
  }

  /**
   * Leader : répliquer le journal aux suiveurs
   */
  private replicateLog(): void {
    if (this.state !== NodeState.Leader) return;

    for (const followerId of this.clusterConfig.peerIds) {
      const nextIdx = this.nextIndex.get(followerId) || 1;

      const prevLogIndex = nextIdx - 1;
      const prevLogTerm = nextIdx > 1 ? this.log[nextIdx - 2].term : 0;
      const entries = this.log.slice(nextIdx - 1);

      const req: AppendEntriesRequest = {
        term: this.currentTerm,
        leaderId: this.nodeId,
        prevLogIndex,
        prevLogTerm,
        entries,
        leaderCommit: this.commitIndex,
      };

      this.sendAppendEntries(followerId, req);
    }
  }

  /**
   * Leader : gérer la réponse AppendEntries
   */
  private handleAppendEntriesResponse(
    followerId: string,
    resp: AppendEntriesResponse,
    req: AppendEntriesRequest
  ): void {
    if (this.state !== NodeState.Leader) return;

    if (resp.term > this.currentTerm) {
      // Le suiveur a un terme supérieur, descendre
      this.currentTerm = resp.term;
      this.state = NodeState.Follower;
      this.votedFor = null;
      return;
    }

    if (resp.success) {
      // Mettre à jour l'index de correspondance et le prochain index
      const lastIndex = req.prevLogIndex + req.entries.length;
      this.matchIndex.set(followerId, lastIndex);
      this.nextIndex.set(followerId, lastIndex + 1);

      // Essayer de valider plus d'entrées
      this.updateCommitIndex();
    } else {
      // Le journal du suiveur est incohérent, revenir en arrière
      const currentNext = this.nextIndex.get(followerId) || 1;
      this.nextIndex.set(followerId, Math.max(1, currentNext - 1));

      // Réessayer immédiatement
      setTimeout(() => this.replicateLog(), 50);
    }
  }

  /**
   * Leader : mettre à jour l'index de validation si la majorité a l'entrée
   */
  private updateCommitIndex(): void {
    if (this.state !== NodeState.Leader) return;

    const N = this.log.length;

    // Trouver le plus grand N tel qu'une majorité ait des entrées de journal jusqu'à N
    for (let i = N; i > this.commitIndex; i--) {
      if (this.log[i - 1].term !== this.currentTerm) {
        continue;
      }

      let count = 1; // Le leader l'a
      for (const matchIdx of this.matchIndex.values()) {
        if (matchIdx >= i) count++;
      }

      const majority = Math.floor(this.clusterConfig.peerIds.length / 2) + 1;
      if (count >= majority) {
        this.commitIndex = i;
        this.applyCommittedEntries();
        break;
      }
    }
  }

  /**
   * Client : soumettre une commande au cluster
   */
  async submitCommand(command: string): Promise<void> {
    if (this.state !== NodeState.Leader) {
      throw new Error('Pas un leader. Rediriger vers le leader actuel.');
    }

    // Ajouter au journal local
    const entry: LogEntry = {
      index: this.log.length + 1,
      term: this.currentTerm,
      command,
    };
    this.log.push(entry);

    // Répliquer aux suiveurs
    this.replicateLog();

    // Attendre la validation
    await this.waitForCommit(entry.index);
  }

  private async waitForCommit(index: number): Promise<void> {
    return new Promise((resolve) => {
      const check = () => {
        if (this.commitIndex >= index) {
          resolve();
        } else {
          setTimeout(check, 50);
        }
      };
      check();
    });
  }
}

Implémentation Python

# types.py
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class LogEntry:
    index: int
    term: int
    command: str

@dataclass
class AppendEntriesRequest:
    term: int
    leader_id: str
    prev_log_index: int
    prev_log_term: int
    entries: List[LogEntry]
    leader_commit: int

@dataclass
class AppendEntriesResponse:
    term: int
    success: bool
# raft_node.py
import asyncio
from enum import Enum
from typing import List, Dict

class RaftNode:
    def __init__(self, node_id: str, peer_ids: List[str]):
        self.node_id = node_id
        self.peer_ids = peer_ids

        # État persistant
        self.current_term = 0
        self.voted_for: Optional[str] = None
        self.log: List[LogEntry] = []

        # État volatil
        self.commit_index = 0
        self.last_applied = 0
        self.state = NodeState.FOLLOWER

        # État du leader
        self.next_index: Dict[str, int] = {}
        self.match_index: Dict[str, int] = {}

        # Machine à états
        self.state_machine = StateMachine()

        # Délai d'élection
        self.election_timeout: Optional[asyncio.Task] = None

    async def handle_append_entries(self, req: AppendEntriesRequest) -> AppendEntriesResponse:
        """Gérer le RPC AppendEntries du leader"""

        # Répondre faux si term < currentTerm
        if req.term < self.current_term:
            return AppendEntriesResponse(term=self.current_term, success=False)

        # Mettre à jour le terme actuel si nécessaire
        if req.term > self.current_term:
            self.current_term = req.term
            self.state = NodeState.FOLLOWER
            self.voted_for = None

        # Réinitialiser le délai d'élection
        self.reset_election_timeout()

        # Vérifier la cohérence du journal
        if req.prev_log_index > 0:
            if len(self.log) < req.prev_log_index:
                return AppendEntriesResponse(term=self.current_term, success=False)

            prev_entry = self.log[req.prev_log_index - 1]
            if prev_entry.term != req.prev_log_term:
                return AppendEntriesResponse(term=self.current_term, success=False)

        # Ajouter de nouvelles entrées
        if req.entries:
            # Trouver la première entrée en conflit
            insert_index = req.prev_log_index
            for entry in req.entries:
                if insert_index < len(self.log):
                    existing = self.log[insert_index]
                    if existing.index == entry.index and existing.term == entry.term:
                        # Correspond déjà, sauter
                        insert_index += 1
                        continue
                    # Conflit ! Supprimer à partir d'ici et ajouter
                    self.log = self.log[:insert_index]
                self.log.append(entry)
                insert_index += 1

        # Mettre à jour l'index de validation
        if req.leader_commit > self.commit_index:
            self.commit_index = min(req.leader_commit, len(self.log))
            await self.apply_committed_entries()

        return AppendEntriesResponse(term=self.current_term, success=True)

    async def apply_committed_entries(self):
        """Appliquer les entrées validées à la machine à états"""
        while self.last_applied < self.commit_index:
            self.last_applied += 1
            entry = self.log[self.last_applied - 1]
            self.state_machine.apply(entry)
            print(f"Nœud {self.node_id} appliqué : {entry.command}")

    async def replicate_log(self):
        """Leader : répliquer le journal aux suiveurs"""
        if self.state != NodeState.LEADER:
            return

        for follower_id in self.peer_ids:
            next_idx = self.next_index.get(follower_id, 1)

            prev_log_index = next_idx - 1
            prev_log_term = self.log[prev_log_index - 1].term if prev_log_index > 0 else 0
            entries = self.log[next_idx - 1:]

            req = AppendEntriesRequest(
                term=self.current_term,
                leader_id=self.node_id,
                prev_log_index=prev_log_index,
                prev_log_term=prev_log_term,
                entries=entries,
                leader_commit=self.commit_index
            )

            await self.send_append_entries(follower_id, req)

    async def handle_append_entries_response(
        self,
        follower_id: str,
        resp: AppendEntriesResponse,
        req: AppendEntriesRequest
    ):
        """Leader : gérer la réponse AppendEntries"""
        if self.state != NodeState.LEADER:
            return

        if resp.term > self.current_term:
            # Le suiveur a un terme supérieur, descendre
            self.current_term = resp.term
            self.state = NodeState.FOLLOWER
            self.voted_for = None
            return

        if resp.success:
            # Mettre à jour l'index de correspondance et le prochain index
            last_index = req.prev_log_index + len(req.entries)
            self.match_index[follower_id] = last_index
            self.next_index[follower_id] = last_index + 1

            # Essayer de valider plus d'entrées
            await self.update_commit_index()
        else:
            # Le journal du suiveur est incohérent, revenir en arrière
            current_next = self.next_index.get(follower_id, 1)
            self.next_index[follower_id] = max(1, current_next - 1)

            # Réessayer immédiatement
            asyncio.create_task(self.replicate_log())

    async def update_commit_index(self):
        """Leader : mettre à jour l'index de validation si la majorité a l'entrée"""
        if self.state != NodeState.LEADER:
            return

        N = len(self.log)

        # Trouver le plus grand N tel qu'une majorité ait des entrées de journal jusqu'à N
        for i in range(N, self.commit_index, -1):
            if self.log[i - 1].term != self.current_term:
                # Ne valider que les entrées du terme actuel
                continue

            count = 1  # Le leader l'a
            for match_idx in self.match_index.values():
                if match_idx >= i:
                    count += 1

            majority = len(self.peer_ids) // 2 + 1
            if count >= majority:
                self.commit_index = i
                await self.apply_committed_entries()
                break

    async def submit_command(self, command: str) -> None:
        """Client : soumettre une commande au cluster"""
        if self.state != NodeState.LEADER:
            raise Exception("Pas un leader. Rediriger vers le leader actuel.")

        # Ajouter au journal local
        entry = LogEntry(
            index=len(self.log) + 1,
            term=self.current_term,
            command=command
        )
        self.log.append(entry)

        # Répliquer aux suiveurs
        await self.replicate_log()

        # Attendre la validation
        await self._wait_for_commit(entry.index)

    async def _wait_for_commit(self, index: int):
        """Attendre qu'une entrée soit validée"""
        while self.commit_index < index:
            await asyncio.sleep(0.05)
# state_machine.py
class StateMachine:
    """Machine à états de magasin clé-valeur simple"""
    def __init__(self):
        self.data: Dict[str, str] = {}

    def apply(self, entry: LogEntry):
        """Appliquer une entrée de journal validée à la machine à états"""
        parts = entry.command.split()
        if parts[0] == "SET" and len(parts) == 3:
            key, value = parts[1], parts[2]
            self.data[key] = value
            print(f"Appliqué : {key} = {value}")
        elif parts[0] == "DELETE" and len(parts) == 2:
            key = parts[1]
            if key in self.data:
                del self.data[key]
                print(f"Supprimé : {key}")

Tests de Réplication de Journal

Test TypeScript

// test-log-replication.ts
async function testLogReplication() {
  const nodes = [
    new RaftNode('node1', ['node2', 'node3']),
    new RaftNode('node2', ['node1', 'node3']),
    new RaftNode('node3', ['node1', 'node2']),
  ];

  // Simuler l'élection de leader (node1 gagne)
  await nodes[0].becomeLeader();

  // Soumettre une commande au leader
  await nodes[0].submitCommand('SET x = 5');

  // Vérifier que tous les nœuds ont l'entrée
  for (const node of nodes) {
    const entry = node.getLog()[0];
    console.log(`${node.nodeId}: ${entry.command}`);
  }
}

Test Python

# test_log_replication.py
import asyncio

async def test_log_replication():
    nodes = [
        RaftNode('node1', ['node2', 'node3']),
        RaftNode('node2', ['node1', 'node3']),
        RaftNode('node3', ['node1', 'node2']),
    ]

    # Simuler l'élection de leader (node1 gagne)
    await nodes[0].become_leader()

    # Soumettre une commande au leader
    await nodes[0].submit_command('SET x = 5')

    # Vérifier que tous les nœuds ont l'entrée
    for node in nodes:
        entry = node.get_log()[0]
        print(f"{node.node_id}: {entry.command}")

asyncio.run(test_log_replication())

Exercices

Exercice 1 : Réplication de Journal de Base

  1. Démarrer un cluster à 3 nœuds
  2. Élire un leader
  3. Soumettre SET x = 10 au leader
  4. Vérifier que l'entrée est sur tous les nœuds
  5. Vérifier l'avancement de l'index de validation

Résultat Attendu : L'entrée apparaît sur tous les nœuds après validation.

Exercice 2 : Résolution de Conflit

  1. Démarrer un cluster à 3 nœuds
  2. Créer une divergence de journal (modifier manuellement les journaux des suiveurs)
  3. Faire répliquer de nouvelles entrées au leader
  4. Observer comment le journal du suiveur est corrigé

Résultat Attendu : Les entrées conflictuelles du suiveur sont écrasées.

Exercice 3 : Sécurité de l'Index de Validation

  1. Démarrer un cluster à 5 nœuds
  2. Partitionner le réseau (2 nœuds isolés)
  3. Soumettre des commandes au leader
  4. Vérifier que les entrées sont validées avec la majorité (3 nœuds)
  5. Guérir la partition
  6. Vérifier que les nœuds isolés rattrapent

Résultat Attendu : Les commandes sont validées avec 3 nœuds, les nœuds isolés rattrapent après guérison.

Exercice 4 : Application de la Machine à États

  1. Implémenter une machine à états de magasin clé-valeur
  2. Soumettre plusieurs commandes SET
  3. Vérifier que la machine à états les applique dans l'ordre
  4. Tuer et redémarrer un nœud
  5. Vérifier que la machine à états est reconstruite à partir du journal

Résultat Attendu : La machine à états reflète toutes les commandes validées, même après redémarrage.


Pièges Courants

PiègeSymptômeSolution
Valider les entrées du terme précédentLes entrées sont perduesNe valider que les entrées du terme actuel
Ne pas appliquer les entrées dans l'ordreÉtat incohérentAppliquer de lastApplied+1 à commitIndex
Boucle de résolution de conflit infiniePic de CPUS'assurer que nextIndex ne descend pas en dessous de 1
Appliquer des entrées non validéesPerte de données lors de la panne du leaderNe jamais appliquer avant commitIndex

Points Clés à Retenir

  1. La réplication de journal assure que tous les nœuds exécutent les mêmes commandes dans le même ordre
  2. Le RPC AppendEntries gère à la fois la réplication et les battements de cœur
  3. La propriété de correspondance de journal permet une résolution de conflit efficace
  4. L'index de validation suit quelles entrées sont répliquées en toute sécurité
  5. La machine à états applique les entrées validées de manière déterministe

Suite : Implémentation Complète du Système de Consensus →

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.

Implémentation du Système de Consensus

Session 10, Partie 2 - 60 minutes

Objectifs d'Apprentissage

  • Construire un système de consensus complet basé sur Raft
  • Implémenter une abstraction de machine à états (magasin clé-valeur)
  • Créer des APIs clientes pour les opérations get/set
  • Déployer et tester le système complet avec Docker Compose
  • Vérifier les propriétés de sécurité et de vivacité

Aperçu : Tout Combiner

Dans les chapitres précédents, nous avons implémenté les deux composants principaux de Raft :

  1. Élection de Leader (Session 9) - Vote démocratique pour sélectionner un leader
  2. Réplication de Journal (Session 10, Partie 1) - Répliquer les commandes à travers les nœuds

Maintenant nous les combinons en un système de consensus complet - un magasin clé-valeur distribué qui fournit des garanties de forte cohérence.

┌────────────────────────────────────────────────────────────┐
│              Système de Consensus Raft                    │
├────────────────────────────────────────────────────────────┤
│                                                             │
│  Client  ──→  Leader  ──→  Réplication de Journal  ──→  Suiveurs │
│     │            │              │                    │      │
│     │            ▼              ▼                    ▼      │
│     │         Élection de Leader (si nécessaire)                │
│     │            │                                          │
│     ▼            ▼                                          ▼
│  Machine à États (tous les nœuds appliquent les mêmes commandes) │
│                                                             │
└────────────────────────────────────────────────────────────┘

Architecture du Système

graph TB
    subgraph "Couche Client"
        C1[Client 1]
        C2[Client 2]
    end

    subgraph "Cluster Raft"
        N1[Nœud 1 : Leader]
        N2[Nœud 2 : Suiveur]
        N3[Nœud 3 : Suiveur]
    end

    subgraph "Couche Machine à États"
        SM1[Magasin CV 1]
        SM2[Magasin CV 2]
        SM3[Magasin CV 3]
    end

    C1 -->|SET/GET| N1
    C2 -->|SET/GET| N1

    N1 <-->|AppendEntries RPC| N2
    N1 <-->|AppendEntries RPC| N3
    N2 <-->|RequestVote RPC| N3

    N1 --> SM1
    N2 --> SM2
    N3 --> SM3

    style N1 fill:#9f9
    style N2 fill:#fc9
    style N3 fill:#fc9

Implémentation Complète TypeScript

Structure du Projet

typescript-raft/
├── package.json
├── tsconfig.json
├── src/
│   ├── types.ts              # Types partagés
│   ├── state-machine.ts      # Machine à états de magasin CV
│   ├── raft-node.ts          # Implémentation Raft complète
│   ├── server.ts             # Serveur API HTTP
│   └── index.ts              # Point d'entrée
└── docker-compose.yml

package.json

{
  "name": "typescript-raft-kv-store",
  "version": "1.0.0",
  "description": "Magasin clé-valeur distribué utilisant le consensus Raft",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts"
  },
  "dependencies": {
    "express": "^4.18.2",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.3"
  }
}

types.ts

// États des nœuds
export enum NodeState {
  FOLLOWER = 'follower',
  CANDIDATE = 'candidate',
  LEADER = 'leader'
}

// Entrée de journal
export interface LogEntry {
  index: number;
  term: number;
  command: string;
}

// RPC RequestVote
export interface RequestVoteRequest {
  term: number;
  candidateId: string;
  lastLogIndex: number;
  lastLogTerm: number;
}

export interface RequestVoteResponse {
  term: number;
  voteGranted: boolean;
}

// RPC AppendEntries
export interface AppendEntriesRequest {
  term: number;
  leaderId: string;
  prevLogIndex: number;
  prevLogTerm: number;
  entries: LogEntry[];
  leaderCommit: number;
}

export interface AppendEntriesResponse {
  term: number;
  success: boolean;
}

// Commandes clientes
export interface SetCommand {
  type: 'SET';
  key: string;
  value: string;
}

export interface GetCommand {
  type: 'GET';
  key: string;
}

export interface DeleteCommand {
  type: 'DELETE';
  key: string;
}

export type Command = SetCommand | GetCommand | DeleteCommand;

state-machine.ts

import { LogEntry } from './types';

/**
 * Machine à États de Magasin Clé-Valeur
 * Applique les entrées de journal validées pour construire un état cohérent
 */
export class KVStoreStateMachine {
  private data: Map<string, string> = new Map();

  /**
   * Appliquer une entrée de journal validée à la machine à états
   */
  apply(entry: LogEntry): void {
    try {
      const command = JSON.parse(entry.command);

      switch (command.type) {
        case 'SET':
          this.data.set(command.key, command.value);
          console.log(`[Machine à États] SET ${command.key} = ${command.value}`);
          break;

        case 'DELETE':
          if (this.data.has(command.key)) {
            this.data.delete(command.key);
            console.log(`[Machine à États] DELETE ${command.key}`);
          }
          break;

        case 'GET':
          // Les commandes en lecture seule ne modifient pas l'état
          break;

        default:
          console.warn(`[Machine à États] Type de commande inconnu : ${command.type}`);
      }
    } catch (error) {
      console.error(`[Machine à États] Échec de l'application de l'entrée :`, error);
    }
  }

  /**
   * Obtenir une valeur de la machine à états
   */
  get(key: string): string | undefined {
    return this.data.get(key);
  }

  /**
   * Obtenir toutes les paires clé-valeur
   */
  getAll(): Record<string, string> {
    return Object.fromEntries(this.data);
  }

  /**
   * Effacer la machine à états (pour les tests)
   */
  clear(): void {
    this.data.clear();
  }
}

raft-node.ts

import {
  NodeState,
  LogEntry,
  RequestVoteRequest,
  RequestVoteResponse,
  AppendEntriesRequest,
  AppendEntriesResponse,
  Command
} from './types';
import { KVStoreStateMachine } from './state-machine';
import axios from 'axios';

interface ClusterConfig {
  nodeId: string;
  peerIds: string[];
  electionTimeoutMin: number;
  electionTimeoutMax: number;
  heartbeatInterval: number;
}

export class RaftNode {
  // Configuration
  private config: ClusterConfig;

  // État persistant (survit aux redémarrages)
  private currentTerm: number = 0;
  private votedFor: string | null = null;
  private log: LogEntry[] = [];

  // État volatil (réinitialisé au redémarrage)
  private commitIndex: number = 0;
  private lastApplied: number = 0;
  private state: NodeState = NodeState.FOLLOWER;

  // État du leader (réinitialisé à l'élection)
  private nextIndex: Map<string, number> = new Map();
  private matchIndex: Map<string, number> = new Map();

  // Composants
  private stateMachine: KVStoreStateMachine;
  private leaderId: string | null = null;

  // Timers
  private electionTimer: NodeJS.Timeout | null = null;
  private heartbeatTimer: NodeJS.Timeout | null = null;

  constructor(config: ClusterConfig) {
    this.config = config;
    this.stateMachine = new KVStoreStateMachine();
    this.resetElectionTimeout();
  }

  // ========== API Publique ==========

  /**
   * Client : Soumettre une commande au cluster
   */
  async submitCommand(command: Command): Promise<any> {
    // Rediriger vers le leader si pas leader
    if (this.state !== NodeState.LEADER) {
      if (this.leaderId) {
        throw new Error(`Pas un leader. Veuillez rediriger vers ${this.leaderId}`);
      }
      throw new Error('Aucun leader connu. Veuillez réessayer.');
    }

    // Gérer les commandes GET (lecture seule, pas de consensus nécessaire)
    if (command.type === 'GET') {
      return this.stateMachine.get(command.key);
    }

    // Ajouter au journal local
    const entry: LogEntry = {
      index: this.log.length + 1,
      term: this.currentTerm,
      command: JSON.stringify(command)
    };
    this.log.push(entry);

    // Répliquer aux suiveurs
    this.replicateLog();

    // Attendre la validation
    await this.waitForCommit(entry.index);

    // Retourner le résultat
    if (command.type === 'SET') {
      return { key: command.key, value: command.value };
    } else if (command.type === 'DELETE') {
      return { key: command.key, deleted: true };
    }
  }

  /**
   * Démarrer le nœud (commencer le délai d'élection)
   */
  start(): void {
    this.resetElectionTimeout();
  }

  /**
   * Arrêter le nœud (effacer les timers)
   */
  stop(): void {
    if (this.electionTimer) clearTimeout(this.electionTimer);
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
  }

  // ========== Gestionnaires RPC ==========

  /**
   * Gérer le RPC RequestVote
   */
  handleRequestVote(req: RequestVoteRequest): RequestVoteResponse {
    // Si term < currentTerm, rejeter
    if (req.term < this.currentTerm) {
      return { term: this.currentTerm, voteGranted: false };
    }

    // Si term > currentTerm, mettre à jour et devenir suiveur
    if (req.term > this.currentTerm) {
      this.currentTerm = req.term;
      this.state = NodeState.FOLLOWER;
      this.votedFor = null;
    }

    // Accorder le vote si :
    // 1. Nous n'avons pas voté ce terme, OU
    // 2. Nous avons voté pour ce candidat
    // ET le journal du candidat est au moins aussi à jour que le nôtre
    const logOk = req.lastLogTerm > this.getLastLogTerm() ||
      (req.lastLogTerm === this.getLastLogTerm() && req.lastLogIndex >= this.log.length);

    const canVote = this.votedFor === null || this.votedFor === req.candidateId;

    if (canVote && logOk) {
      this.votedFor = req.candidateId;
      this.resetElectionTimeout();
      return { term: this.currentTerm, voteGranted: true };
    }

    return { term: this.currentTerm, voteGranted: false };
  }

  /**
   * Gérer le RPC AppendEntries
   */
  handleAppendEntries(req: AppendEntriesRequest): AppendEntriesResponse {
    // Si term < currentTerm, rejeter
    if (req.term < this.currentTerm) {
      return { term: this.currentTerm, success: false };
    }

    // Reconnaître le leader
    this.leaderId = req.leaderId;

    // Si term > currentTerm, mettre à jour et devenir suiveur
    if (req.term > this.currentTerm) {
      this.currentTerm = req.term;
      this.state = NodeState.FOLLOWER;
      this.votedFor = null;
    }

    // Réinitialiser le délai d'élection
    this.resetElectionTimeout();

    // Vérifier la cohérence du journal
    if (req.prevLogIndex > 0) {
      if (this.log.length < req.prevLogIndex) {
        return { term: this.currentTerm, success: false };
      }

      const prevEntry = this.log[req.prevLogIndex - 1];
      if (prevEntry.term !== req.prevLogTerm) {
        return { term: this.currentTerm, success: false };
      }
    }

    // Ajouter de nouvelles entrées
    if (req.entries.length > 0) {
      let insertIndex = req.prevLogIndex;
      for (const entry of req.entries) {
        if (insertIndex < this.log.length) {
          const existing = this.log[insertIndex];
          if (existing.index === entry.index && existing.term === entry.term) {
            insertIndex++;
            continue;
          }
          // Conflit ! Supprimer à partir d'ici
          this.log = this.log.slice(0, insertIndex);
        }
        this.log.push(entry);
        insertIndex++;
      }
    }

    // Mettre à jour l'index de validation
    if (req.leaderCommit > this.commitIndex) {
      this.commitIndex = Math.min(req.leaderCommit, this.log.length);
      this.applyCommittedEntries();
    }

    return { term: this.currentTerm, success: true };
  }

  // ========== Méthodes Privées ==========

  /**
   * Démarrer l'élection (convertir en candidat)
   */
  private startElection(): void {
    this.state = NodeState.CANDIDATE;
    this.currentTerm++;
    this.votedFor = this.config.nodeId;
    this.leaderId = null;

    console.log(`[Nœud ${this.config.nodeId}] Démarrage de l'élection pour le terme ${this.currentTerm}`);

    // Demander les votes des pairs
    const req: RequestVoteRequest = {
      term: this.currentTerm,
      candidateId: this.config.nodeId,
      lastLogIndex: this.log.length,
      lastLogTerm: this.getLastLogTerm()
    };

    let votesReceived = 1; // Vote pour soi-même
    const majority = Math.floor(this.config.peerIds.length / 2) + 1;

    for (const peerId of this.config.peerIds) {
      this.sendRequestVote(peerId, req).then(resp => {
        if (resp.voteGranted) {
          votesReceived++;
          if (votesReceived >= majority && this.state === NodeState.CANDIDATE) {
            this.becomeLeader();
          }
        } else if (resp.term > this.currentTerm) {
          this.currentTerm = resp.term;
          this.state = NodeState.FOLLOWER;
          this.votedFor = null;
        }
      }).catch(() => {
        // Pair indisponible, ignorer
      });
    }

    // Réinitialiser le délai d'élection pour le prochain tour
    this.resetElectionTimeout();
  }

  /**
   * Devenir leader après avoir gagné l'élection
   */
  private becomeLeader(): void {
    this.state = NodeState.LEADER;
    this.leaderId = this.config.nodeId;
    console.log(`[Nœud ${this.config.nodeId}] Devient leader pour le terme ${this.currentTerm}`);

    // Initialiser l'état du leader
    for (const peerId of this.config.peerIds) {
      this.nextIndex.set(peerId, this.log.length + 1);
      this.matchIndex.set(peerId, 0);
    }

    // Commencer à envoyer des battements de cœur
    this.startHeartbeats();
  }

  /**
   * Envoyer des battements de cœur à tous les suiveurs
   */
  private startHeartbeats(): void {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);

    this.heartbeatTimer = setInterval(() => {
      if (this.state === NodeState.LEADER) {
        this.replicateLog();
      }
    }, this.config.heartbeatInterval);
  }

  /**
   * Répliquer le journal aux suiveurs (envoie aussi les battements de cœur)
   */
  private replicateLog(): void {
    if (this.state !== NodeState.LEADER) return;

    for (const followerId of this.config.peerIds) {
      const nextIdx = this.nextIndex.get(followerId) || 1;
      const prevLogIndex = nextIdx - 1;
      const prevLogTerm = prevLogIndex > 0 ? this.log[prevLogIndex - 1].term : 0;
      const entries = this.log.slice(nextIdx - 1);

      const req: AppendEntriesRequest = {
        term: this.currentTerm,
        leaderId: this.config.nodeId,
        prevLogIndex,
        prevLogTerm,
        entries,
        leaderCommit: this.commitIndex
      };

      this.sendAppendEntries(followerId, req).then(resp => {
        if (this.state !== NodeState.LEADER) return;

        if (resp.term > this.currentTerm) {
          this.currentTerm = resp.term;
          this.state = NodeState.FOLLOWER;
          this.votedFor = null;
          if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
          return;
        }

        if (resp.success) {
          const lastIndex = prevLogIndex + entries.length;
          this.matchIndex.set(followerId, lastIndex);
          this.nextIndex.set(followerId, lastIndex + 1);
          this.updateCommitIndex();
        } else {
          const currentNext = this.nextIndex.get(followerId) || 1;
          this.nextIndex.set(followerId, Math.max(1, currentNext - 1));
        }
      }).catch(() => {
        // Suiveur indisponible, réessayera
      });
    }
  }

  /**
   * Mettre à jour l'index de validation si la majorité a l'entrée
   */
  private updateCommitIndex(): void {
    if (this.state !== NodeState.LEADER) return;

    const N = this.log.length;
    const majority = Math.floor(this.config.peerIds.length / 2) + 1;

    for (let i = N; i > this.commitIndex; i--) {
      if (this.log[i - 1].term !== this.currentTerm) continue;

      let count = 1; // Le leader l'a
      for (const matchIdx of this.matchIndex.values()) {
        if (matchIdx >= i) count++;
      }

      if (count >= majority) {
        this.commitIndex = i;
        this.applyCommittedEntries();
        break;
      }
    }
  }

  /**
   * Appliquer les entrées validées à la machine à états
   */
  private applyCommittedEntries(): void {
    while (this.lastApplied < this.commitIndex) {
      this.lastApplied++;
      const entry = this.log[this.lastApplied - 1];
      this.stateMachine.apply(entry);
    }
  }

  /**
   * Attendre qu'une entrée soit validée
   */
  private async waitForCommit(index: number): Promise<void> {
    return new Promise((resolve) => {
      const check = () => {
        if (this.commitIndex >= index) {
          resolve();
        } else {
          setTimeout(check, 50);
        }
      };
      check();
    });
  }

  /**
   * Réinitialiser le délai d'élection avec une valeur aléatoire
   */
  private resetElectionTimeout(): void {
    if (this.electionTimer) clearTimeout(this.electionTimer);

    const timeout = this.randomTimeout();
    this.electionTimer = setTimeout(() => {
      if (this.state !== NodeState.LEADER) {
        this.startElection();
      }
    }, timeout);
  }

  private randomTimeout(): number {
    const min = this.config.electionTimeoutMin;
    const max = this.config.electionTimeoutMax;
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  private getLastLogTerm(): number {
    if (this.log.length === 0) return 0;
    return this.log[this.log.length - 1].term;
  }

  // ========== Couche Réseau (simplifiée) ==========

  private async sendRequestVote(peerId: string, req: RequestVoteRequest): Promise<RequestVoteResponse> {
    const url = `http://${peerId}/raft/request-vote`;
    const response = await axios.post(url, req);
    return response.data;
  }

  private async sendAppendEntries(peerId: string, req: AppendEntriesRequest): Promise<AppendEntriesResponse> {
    const url = `http://${peerId}/raft/append-entries`;
    const response = await axios.post(url, req);
    return response.data;
  }

  // ========== Méthodes de Débogage ==========

  getState() {
    return {
      nodeId: this.config.nodeId,
      state: this.state,
      term: this.currentTerm,
      leaderId: this.leaderId,
      logLength: this.log.length,
      commitIndex: this.commitIndex,
      stateMachine: this.stateMachine.getAll()
    };
  }
}

server.ts

import express from 'express';
import { RaftNode } from './raft-node';
import { Command } from './types';

export function createServer(node: RaftNode, port: number): express.Application {
  const app = express();
  app.use(express.json());

  // Points de terminaison RPC Raft
  app.post('/raft/request-vote', (req, res) => {
    const response = node.handleRequestVote(req.body);
    res.json(response);
  });

  app.post('/raft/append-entries', (req, res) => {
    const response = node.handleAppendEntries(req.body);
    res.json(response);
  });

  // Points de terminaison API Client
  app.get('/kv/:key', (req, res) => {
    const command: Command = { type: 'GET', key: req.params.key };
    node.submitCommand(command)
      .then(value => res.json({ key: req.params.key, value }))
      .catch(err => res.status(500).json({ error: err.message }));
  });

  app.post('/kv', (req, res) => {
    const command: Command = { type: 'SET', key: req.body.key, value: req.body.value };
    node.submitCommand(command)
      .then(result => res.json(result))
      .catch(err => res.status(500).json({ error: err.message }));
  });

  app.delete('/kv/:key', (req, res) => {
    const command: Command = { type: 'DELETE', key: req.params.key };
    node.submitCommand(command)
      .then(result => res.json(result))
      .catch(err => res.status(500).json({ error: err.message }));
  });

  // Point de terminaison de débogage
  app.get('/debug', (req, res) => {
    res.json(node.getState());
  });

  return app;
}

index.ts

import { RaftNode } from './raft-node';
import { createServer } from './server';

const NODE_ID = process.env.NODE_ID || 'node1';
const PEER_IDS = process.env.PEER_IDS?.split(',') || [];
const PORT = parseInt(process.env.PORT || '3000');

const node = new RaftNode({
  nodeId: NODE_ID,
  peerIds: PEER_IDS,
  electionTimeoutMin: 150,
  electionTimeoutMax: 300,
  heartbeatInterval: 50
});

node.start();

const app = createServer(node, PORT);
app.listen(PORT, () => {
  console.log(`Nœud ${NODE_ID} écoute sur le port ${PORT}`);
  console.log(`Pairs : ${PEER_IDS.join(', ')}`);
});

docker-compose.yml

version: '3.8'

services:
  node1:
    build: .
    container_name: raft-node1
    environment:
      - NODE_ID=node1
      - PEER_IDS=node2:3000,node3:3000
      - PORT=3000
    ports:
      - "3001:3000"

  node2:
    build: .
    container_name: raft-node2
    environment:
      - NODE_ID=node2
      - PEER_IDS=node1:3000,node3:3000
      - PORT=3000
    ports:
      - "3002:3000"

  node3:
    build: .
    container_name: raft-node3
    environment:
      - NODE_ID=node3
      - PEER_IDS=node1:3000,node2:3000
      - PORT=3000
    ports:
      - "3003:3000"

Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]

Implémentation Complète Python

Structure du Projet

python-raft/
├── requirements.txt
├── src/
│   ├── types.py              # Types partagés
│   ├── state_machine.py      # Machine à états de magasin CV
│   ├── raft_node.py          # Implémentation Raft complète
│   ├── server.py             # Serveur API Flask
│   └── __init__.py
├── app.py                    # Point d'entrée
└── docker-compose.yml

requirements.txt

flask==3.0.0
requests==2.31.0
gunicorn==21.2.0

types.py

from enum import Enum
from dataclasses import dataclass
from typing import List, Optional, Union

class NodeState(Enum):
    FOLLOWER = "follower"
    CANDIDATE = "candidate"
    LEADER = "leader"

@dataclass
class LogEntry:
    index: int
    term: int
    command: str

@dataclass
class RequestVoteRequest:
    term: int
    candidate_id: str
    last_log_index: int
    last_log_term: int

@dataclass
class RequestVoteResponse:
    term: int
    vote_granted: bool

@dataclass
class AppendEntriesRequest:
    term: int
    leader_id: str
    prev_log_index: int
    prev_log_term: int
    entries: List[LogEntry]
    leader_commit: int

@dataclass
class AppendEntriesResponse:
    term: int
    success: bool

@dataclass
class SetCommand:
    type: str = 'SET'
    key: str = ''
    value: str = ''

@dataclass
class GetCommand:
    type: str = 'GET'
    key: str = ''

@dataclass
class DeleteCommand:
    type: str = 'DELETE'
    key: str = ''

Command = Union[SetCommand, GetCommand, DeleteCommand]

state_machine.py

from typing import Dict, Optional
import json
from .types import LogEntry

class KVStoreStateMachine:
    """Machine à États de Magasin Clé-Valeur"""

    def __init__(self):
        self.data: Dict[str, str] = {}

    def apply(self, entry: LogEntry) -> None:
        """Appliquer une entrée de journal validée à la machine à états"""
        try:
            command = json.loads(entry.command)

            if command['type'] == 'SET':
                self.data[command['key']] = command['value']
                print(f"[Machine à États] SET {command['key']} = {command['value']}")

            elif command['type'] == 'DELETE':
                if command['key'] in self.data:
                    del self.data[command['key']]
                    print(f"[Machine à États] DELETE {command['key']}")

            elif command['type'] == 'GET':
                # Lecture seule, pas de changement d'état
                pass

        except Exception as e:
            print(f"[Machine à États] Échec de l'application de l'entrée : {e}")

    def get(self, key: str) -> Optional[str]:
        """Obtenir une valeur de la machine à états"""
        return self.data.get(key)

    def get_all(self) -> Dict[str, str]:
        """Obtenir toutes les paires clé-valeur"""
        return dict(self.data)

    def clear(self) -> None:
        """Effacer la machine à états (pour les tests)"""
        self.data.clear()

raft_node.py

import asyncio
import random
import json
from typing import Dict, List, Optional
from .types import (
    NodeState, LogEntry, RequestVoteRequest, RequestVoteResponse,
    AppendEntriesRequest, AppendEntriesResponse, Command
)
from .state_machine import KVStoreStateMachine
import requests

class ClusterConfig:
    nodeId: str
    peer_ids: List[str]
    election_timeout_min: int
    election_timeout_max: int
    heartbeat_interval: int

    def __init__(self, node_id: str, peer_ids: List[str],
                 election_timeout_min: int = 150,
                 election_timeout_max: int = 300,
                 heartbeat_interval: int = 50):
        self.nodeId = node_id
        self.peer_ids = peer_ids
        self.election_timeout_min = election_timeout_min
        self.election_timeout_max = election_timeout_max
        self.heartbeat_interval = heartbeat_interval

class RaftNode:
    def __init__(self, config: ClusterConfig):
        self.config = config
        self.state_machine = KVStoreStateMachine()

        # État persistant
        self.current_term = 0
        self.voted_for: Optional[str] = None
        self.log: List[LogEntry] = []

        # État volatil
        self.commit_index = 0
        self.last_applied = 0
        self.state = NodeState.FOLLOWER
        self.leader_id: Optional[str] = None

        # État du leader
        self.next_index: Dict[str, int] = {}
        self.match_index: Dict[str, int] = {}

        # Timers
        self.election_task: Optional[asyncio.Task] = None
        self.heartbeat_task: Optional[asyncio.Task] = None

    # ========== API Publique ==========

    async def submit_command(self, command: Command) -> any:
        """Client : Soumettre une commande au cluster"""

        # Rediriger vers le leader si pas leader
        if self.state != NodeState.LEADER:
            if self.leader_id:
                raise Exception(f"Pas un leader. Veuillez rediriger vers {self.leader_id}")
            raise Exception("Aucun leader connu. Veuillez réessayer.")

        # Gérer les commandes GET (lecture seule)
        if command.type == 'GET':
            return self.state_machine.get(command.key)

        # Ajouter au journal local
        entry = LogEntry(
            index=len(self.log) + 1,
            term=self.current_term,
            command=json.dumps(command.__dict__)
        )
        self.log.append(entry)

        # Répliquer aux suiveurs
        await self.replicate_log()

        # Attendre la validation
        await self._wait_for_commit(entry.index)

        # Retourner le résultat
        if command.type == 'SET':
            return {"key": command.key, "value": command.value}
        elif command.type == 'DELETE':
            return {"key": command.key, "deleted": True}

    def start(self):
        """Démarrer le nœud"""
        asyncio.create_task(self._election_loop())

    def stop(self):
        """Arrêter le nœud"""
        if self.election_task:
            self.election_task.cancel()
        if self.heartbeat_task:
            self.heartbeat_task.cancel()

    # ========== Gestionnaires RPC ==========

    def handle_request_vote(self, req: RequestVoteRequest) -> RequestVoteResponse:
        """Gérer le RPC RequestVote"""

        if req.term < self.current_term:
            return RequestVoteResponse(term=self.current_term, vote_granted=False)

        if req.term > self.current_term:
            self.current_term = req.term
            self.state = NodeState.FOLLOWER
            self.voted_for = None

        log_ok = (req.last_log_term > self._get_last_log_term() or
                  (req.last_log_term == self._get_last_log_term() and
                   req.last_log_index >= len(self.log)))

        can_vote = self.voted_for is None or self.voted_for == req.candidate_id

        if can_vote and log_ok:
            self.voted_for = req.candidate_id
            return RequestVoteResponse(term=self.current_term, vote_granted=True)

        return RequestVoteResponse(term=self.current_term, vote_granted=False)

    def handle_append_entries(self, req: AppendEntriesRequest) -> AppendEntriesResponse:
        """Gérer le RPC AppendEntries"""

        if req.term < self.current_term:
            return AppendEntriesResponse(term=self.current_term, success=False)

        # Reconnaître le leader
        self.leader_id = req.leader_id

        if req.term > self.current_term:
            self.current_term = req.term
            self.state = NodeState.FOLLOWER
            self.voted_for = None

        # Vérifier la cohérence du journal
        if req.prev_log_index > 0:
            if len(self.log) < req.prev_log_index:
                return AppendEntriesResponse(term=self.current_term, success=False)

            prev_entry = self.log[req.prev_log_index - 1]
            if prev_entry.term != req.prev_log_term:
                return AppendEntriesResponse(term=self.current_term, success=False)

        # Ajouter de nouvelles entrées
        if req.entries:
            insert_index = req.prev_log_index
            for entry in req.entries:
                if insert_index < len(self.log):
                    existing = self.log[insert_index]
                    if existing.index == entry.index and existing.term == entry.term:
                        insert_index += 1
                        continue
                    self.log = self.log[:insert_index]
                self.log.append(entry)
                insert_index += 1

        # Mettre à jour l'index de validation
        if req.leader_commit > self.commit_index:
            self.commit_index = min(req.leader_commit, len(self.log))
            self._apply_committed_entries()

        return AppendEntriesResponse(term=self.current_term, success=True)

    # ========== Méthodes Privées ==========

    async def _election_loop(self):
        """Boucle de délai d'élection"""
        while True:
            timeout = self._random_timeout()
            await asyncio.sleep(timeout / 1000)

            if self.state != NodeState.LEADER:
                await self._start_election()

    async def _start_election(self):
        """Démarrer l'élection (convertir en candidat)"""
        self.state = NodeState.CANDIDATE
        self.current_term += 1
        self.voted_for = self.config.nodeId
        self.leader_id = None

        print(f"[Nœud {self.config.nodeId}] Démarrage de l'élection pour le terme {self.current_term}")

        req = RequestVoteRequest(
            term=self.current_term,
            candidate_id=self.config.nodeId,
            last_log_index=len(self.log),
            last_log_term=self._get_last_log_term()
        )

        votes_received = 1  # Vote pour soi-même
        majority = len(self.config.peer_ids) // 2 + 1

        tasks = []
        for peer_id in self.config.peer_ids:
            tasks.append(self._send_request_vote(peer_id, req))

        results = await asyncio.gather(*tasks, return_exceptions=True)

        for result in results:
            if isinstance(result, RequestVoteResponse):
                if result.vote_granted:
                    votes_received += 1
                    if votes_received >= majority and self.state == NodeState.CANDIDATE:
                        self._become_leader()
                elif result.term > self.current_term:
                    self.current_term = result.term
                    self.state = NodeState.FOLLOWER
                    self.voted_for = None

    def _become_leader(self):
        """Devenir leader après avoir gagné l'élection"""
        self.state = NodeState.LEADER
        self.leader_id = self.config.nodeId
        print(f"[Nœud {self.config.nodeId}] Devient leader pour le terme {self.current_term}")

        # Initialiser l'état du leader
        for peer_id in self.config.peer_ids:
            self.next_index[peer_id] = len(self.log) + 1
            self.match_index[peer_id] = 0

        # Démarrer les battements de cœur
        asyncio.create_task(self._heartbeat_loop())

    async def _heartbeat_loop(self):
        """Envoyer des battements de cœur aux suiveurs"""
        while self.state == NodeState.LEADER:
            await self.replicate_log()
            await asyncio.sleep(self.config.heartbeat_interval / 1000)

    async def replicate_log(self):
        """Répliquer le journal aux suiveurs"""
        if self.state != NodeState.LEADER:
            return

        tasks = []
        for follower_id in self.config.peer_ids:
            next_idx = self.next_index.get(follower_id, 1)
            prev_log_index = next_idx - 1
            prev_log_term = self.log[prev_log_index - 1].term if prev_log_index > 0 else 0
            entries = self.log[next_idx - 1:]

            req = AppendEntriesRequest(
                term=self.current_term,
                leader_id=self.config.nodeId,
                prev_log_index=prev_log_index,
                prev_log_term=prev_log_term,
                entries=entries,
                leader_commit=self.commit_index
            )

            tasks.append(self._send_append_entries(follower_id, req))

        results = await asyncio.gather(*tasks, return_exceptions=True)

        for i, result in enumerate(results):
            follower_id = self.config.peer_ids[i]
            if isinstance(result, AppendEntriesResponse):
                if result.term > self.current_term:
                    self.current_term = result.term
                    self.state = NodeState.FOLLOWER
                    self.voted_for = None
                    return

                if result.success:
                    last_index = len(self.log) if self.log else 0
                    self.match_index[follower_id] = last_index
                    self.next_index[follower_id] = last_index + 1
                    await self._update_commit_index()
                else:
                    current_next = self.next_index.get(follower_id, 1)
                    self.next_index[follower_id] = max(1, current_next - 1)

    async def _update_commit_index(self):
        """Mettre à jour l'index de validation si la majorité a l'entrée"""
        if self.state != NodeState.LEADER:
            return

        N = len(self.log)
        majority = len(self.config.peer_ids) // 2 + 1

        for i in range(N, self.commit_index, -1):
            if self.log[i - 1].term != self.current_term:
                continue

            count = 1  # Le leader l'a
            for match_idx in self.match_index.values():
                if match_idx >= i:
                    count += 1

            if count >= majority:
                self.commit_index = i
                self._apply_committed_entries()
                break

    def _apply_committed_entries(self):
        """Appliquer les entrées validées à la machine à états"""
        while self.last_applied < self.commit_index:
            self.last_applied += 1
            entry = self.log[self.last_applied - 1]
            self.state_machine.apply(entry)

    async def _wait_for_commit(self, index: int):
        """Attendre qu'une entrée soit validée"""
        while self.commit_index < index:
            await asyncio.sleep(0.05)

    def _random_timeout(self) -> int:
        """Générer un délai d'élection aléatoire"""
        return random.randint(
            self.config.election_timeout_min,
            self.config.election_timeout_max
        )

    def _get_last_log_term(self) -> int:
        """Obtenir le terme de la dernière entrée de journal"""
        if not self.log:
            return 0
        return self.log[-1].term

    # ========== Couche Réseau ==========

    async def _send_request_vote(self, peer_id: str, req: RequestVoteRequest) -> RequestVoteResponse:
        """Envoyer le RPC RequestVote au pair"""
        url = f"http://{peer_id}/raft/request-vote"
        try:
            response = requests.post(url, json=req.__dict__, timeout=1)
            return RequestVoteResponse(**response.json())
        except:
            return RequestVoteResponse(term=self.current_term, vote_granted=False)

    async def _send_append_entries(self, peer_id: str, req: AppendEntriesRequest) -> AppendEntriesResponse:
        """Envoyer le RPC AppendEntries au pair"""
        url = f"http://{peer_id}/raft/append-entries"
        try:
            data = {
                'term': req.term,
                'leaderId': req.leader_id,
                'prevLogIndex': req.prev_log_index,
                'prevLogTerm': req.prev_log_term,
                'entries': [e.__dict__ for e in req.entries],
                'leaderCommit': req.leader_commit
            }
            response = requests.post(url, json=data, timeout=1)
            return AppendEntriesResponse(**response.json())
        except:
            return AppendEntriesResponse(term=self.current_term, success=False)

    # ========== Méthodes de Débogage ==========

    def get_state(self) -> dict:
        """Obtenir l'état du nœud pour le débogage"""
        return {
            'nodeId': self.config.nodeId,
            'state': self.state.value,
            'term': self.current_term,
            'leaderId': self.leader_id,
            'logLength': len(self.log),
            'commitIndex': self.commit_index,
            'stateMachine': self.state_machine.get_all()
        }

server.py

from flask import Flask, request, jsonify
from .raft_node import RaftNode, ClusterConfig

def create_server(node: RaftNode):
    app = Flask(__name__)

    # Points de terminaison RPC Raft
    @app.route('/raft/request-vote', methods=['POST'])
    def request_vote():
        response = node.handle_request_vote(
            RequestVoteResponse(**request.json)
        )
        return jsonify(response.__dict__)

    @app.route('/raft/append-entries', methods=['POST'])
    def append_entries():
        # Convertir la requête au format approprié
        data = request.json
        entries = [LogEntry(**e) for e in data.get('entries', [])]
        req = AppendEntriesRequest(
            term=data['term'],
            leader_id=data['leaderId'],
            prev_log_index=data['prevLogIndex'],
            prev_log_term=data['prevLogTerm'],
            entries=entries,
            leader_commit=data['leaderCommit']
        )
        response = node.handle_append_entries(req)
        return jsonify(response.__dict__)

    # Points de terminaison API Client
    @app.route('/kv/<key>', methods=['GET'])
    def get_key(key):
        command = GetCommand(key=key)
        try:
            value = asyncio.run(node.submit_command(command))
            return jsonify({'key': key, 'value': value})
        except Exception as e:
            return jsonify({'error': str(e)}), 500

    @app.route('/kv', methods=['POST'])
    def set_key():
        command = SetCommand(key=request.json['key'], value=request.json['value'])
        try:
            result = asyncio.run(node.submit_command(command))
            return jsonify(result)
        except Exception as e:
            return jsonify({'error': str(e)}), 500

    @app.route('/kv/<key>', methods=['DELETE'])
    def delete_key(key):
        command = DeleteCommand(key=key)
        try:
            result = asyncio.run(node.submit_command(command))
            return jsonify(result)
        except Exception as e:
            return jsonify({'error': str(e)}), 500

    # Point de terminaison de débogage
    @app.route('/debug', methods=['GET'])
    def debug():
        return jsonify(node.get_state())

    return app

app.py

import os
from src.types import ClusterConfig
from src.raft_node import RaftNode
from src.server import create_server

NODE_ID = os.getenv('NODE_ID', 'node1')
PEER_IDS = os.getenv('PEER_IDS', '').split(',') if os.getenv('PEER_IDS') else []
PORT = int(os.getenv('PORT', '5000'))

config = ClusterConfig(
    node_id=NODE_ID,
    peer_ids=PEER_IDS
)

node = RaftNode(config)
node.start()

app = create_server(node)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=PORT)

docker-compose.yml (Python)

version: '3.8'

services:
  node1:
    build: .
    container_name: python-raft-node1
    environment:
      - NODE_ID=node1
      - PEER_IDS=node2:5000,node3:5000
      - PORT=5000
    ports:
      - "5001:5000"

  node2:
    build: .
    container_name: python-raft-node2
    environment:
      - NODE_ID=node2
      - PEER_IDS=node1:5000,node3:5000
      - PORT=5000
    ports:
      - "5002:5000"

  node3:
    build: .
    container_name: python-raft-node3
    environment:
      - NODE_ID=node3
      - PEER_IDS=node1:5000,node2:5000
      - PORT=5000
    ports:
      - "5003:5000"

Dockerfile (Python)

FROM python:3.11-alpine

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"]

Exécution du Système

TypeScript

# Construire
npm run build

# Exécuter avec Docker Compose
docker-compose up

# Tester le cluster
curl -X POST http://localhost:3001/kv -H "Content-Type: application/json" -d '{"key":"foo","value":"bar"}'
curl http://localhost:3001/kv/foo
curl http://localhost:3002/debug  # Vérifier l'état du nœud

Python

# Exécuter avec Docker Compose
docker-compose up

# Tester le cluster
curl -X POST http://localhost:5001/kv -H "Content-Type: application/json" -d '{"key":"foo","value":"bar"}'
curl http://localhost:5001/kv/foo
curl http://localhost:5002/debug  # Vérifier l'état du nœud

Exercices

Exercice 1 : Opérations de Base

  1. Démarrer le cluster à 3 nœuds
  2. Attendre l'élection du leader
  3. SET key=value sur le leader
  4. GET la clé de tous les nœuds
  5. Vérifier que tous les nœuds retournent la même valeur

Résultat Attendu : Tous les nœuds retournent la valeur validée.

Exercice 2 : Bascullement de Leader

  1. Démarrer le cluster et écrire des données
  2. Tuer le conteneur leader
  3. Observer un nouveau leader être élu
  4. Continuer à écrire des données
  5. Redémarrer l'ancien leader
  6. Vérifier qu'il rattrape

Résultat Attendu : Le système continue à fonctionner avec le nouveau leader, l'ancien leader rejoint en tant que suiveur.

Exercice 3 : Partition Réseau

  1. Démarrer un cluster à 5 nœuds
  2. Isoler 2 nœuds (simuler une partition)
  3. Vérifier que la majorité (3 nœuds) peut encore valider
  4. Guérir la partition
  5. Vérifier que les nœuds isolés rattrapent

Résultat Attendu : Le côté majorité continue, la minorité ne peut pas valider, la rejointe fonctionne.

Exercice 4 : Test de Persistance

  1. Écrire des données dans le cluster
  2. Arrêter tous les nœuds
  3. Redémarrer tous les nœuds
  4. Vérifier que les données sont récupérées

Résultat Attendu : Toutes les données survivent au redémarrage.


Pièges Courants

PiègeSymptômeSolution
Lire à partir des suiveursLectures staléesToujours lire à partir du leader ou implémenter des lectures avec bail
Pas de battements de cœurÉlections inutilesS'assurer que le timer de battement de cœur fonctionne continuellement
Délai d'attente du clientÉcritures échouéesAttendre la validation, ne pas retourner immédiatement
Split brainLeaders multiplesLes délais randomisés + les règles de vote empêchent cela

Points Clés à Retenir

  1. Raft Complet combine l'élection de leader + la réplication de journal pour le consensus
  2. La machine à états applique les commandes validées de manière déterministe
  3. L'API client fournit un accès transparent au système distribué
  4. Le basculement est automatique - un nouveau leader est élu quand l'ancien échoue
  5. La sécurité garantit assure qu'il n'y a pas d'écritures conflictuelles

Félicitations ! Vous avez complété le Système de Consensus. Vous comprenez maintenant l'un des concepts les plus difficiles des systèmes distribués !

Suite : Matériels de Référence →

🧠 Quiz du Chapitre

Testez votre maîtrise de ces concepts ! Ces questions défieront votre compréhension et révéleront les lacunes dans vos connaissances.

Configuration Docker

Ce guide couvre l'installation de Docker et Docker Compose pour exécuter les exemples du cours.

Installation de Docker

Linux

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER

macOS

Téléchargez Docker Desktop depuis docker.com

Windows

Téléchargez Docker Desktop depuis docker.com

Vérifier l'Installation

docker --version
docker-compose --version

Exécuter les Exemples du Cours

Chaque chapitre inclut un fichier Docker Compose :

cd examples/01-queue
docker-compose up

Commandes Courantes

# Démarrer les services
docker-compose up

# Démarrer en arrière-plan
docker-compose up -d

# Voir les journaux
docker-compose logs

# Arrêter les services
docker-compose down

# Reconstruire après des changements de code
docker-compose up --build

Dépannage

Voir Dépannage pour les problèmes courants.

Dépannage

Problèmes courants et solutions lors de l'utilisation des exemples du cours.

Problèmes Docker

Port Déjà Utilisé

Error: bind: address already in use

Solution : Changez le port dans docker-compose.yml ou arrêtez le service en conflit.

Permission Refusée

Error: permission denied while trying to connect to the Docker daemon

Solution : Ajoutez votre utilisateur au groupe docker :

sudo usermod -aG docker $USER
newgrp docker

Problèmes de Build

TypeScript : Module Non Trouvé

Solution : Installez les dépendances :

npm install

Python : Module Non Trouvé

Solution : Installez les dépendances :

pip install -r requirements.txt

Problèmes d'Exécution

Connexion Refusée

Solution : Vérifiez que tous les services sont en cours d'exécution :

docker-compose ps

Le Nœud ne Peut pas se Connecter aux Pairs

Solution : Vérifiez la configuration réseau dans docker-compose.yml. Assurez-vous que tous les nœuds sont sur le même réseau.

Obtenir de l'Aide

Si vous rencontrez des problèmes non couverts ici :

  1. Consultez les journaux Docker : docker-compose logs
  2. Vérifiez votre installation Docker : docker --version
  3. Voir Pour Aller Plus Loin pour des ressources supplémentaires

Pour Aller Plus Loin

Ressources pour approfondir votre compréhension des systèmes distribués.

Livres

TitreAuteurFocus
Designing Data-Intensive ApplicationsMartin KleppmannConception moderne de bases de données et systèmes distribués
Distributed Systems: Principles and ParadigmsTanenbaum & van SteenFondements académiques
Introduction to Reliable Distributed ProgrammingCachin, Guerraoui, RodriguesFondements formels

Articles

Fondamentaux

  • Brewer, E. A. (2000). "Towards robust distributed systems"
  • Gilbert, S. & Lynch, N. (2002). "Brewer's conjecture and the feasibility of consistent, available, partition-tolerant web services"
  • Fischer, M. J., Lynch, N. A., & Paterson, M. S. (1985). "Impossibility of distributed consensus with one faulty process"

Consensus

  • Ongaro, D. & Ousterhout, J. (2014). "In Search of an Understandable Consensus Algorithm (Raft)"
  • Lamport, L. (2001). "Paxos Made Simple"

Ressources en Ligne

Cours Vidéo

  • MIT 6.824: Distributed Systems
  • Stanford CS247: Advanced Distributed Systems

Pratique

  • Construisez votre propre système distribué à partir de zéro
  • Contribuez à des bases de données distribuées open source
  • Participez à des hackathons de systèmes distribués