Aller au contenu
Blockchain Association of Cameroon
BAC-L0-FOUND-1033 h

Bases de données, journaux et état

Un registre distribué est d'abord un journal ordonné dont l'état est une projection. Cet atelier installe cette idée avant qu'elle ne devienne une blockchain.

  • Distinguer un journal d'événements de l'état qui en résulte
  • Rejouer un journal pour reconstruire un état vérifiable
  • Expliquer atomicité et durabilité sur un cas concret

Quand vous consultez votre solde de monnaie mobile, vous lisez le résultat d'une longue suite d'opérations passées, pas une valeur écrite au hasard : le relevé est le journal, le solde est l'état qui en découle. Cet atelier vous apprend à distinguer les deux, à rejouer un journal pour reconstruire un état que n'importe qui peut revérifier, et à comprendre sur un cas concret ce que veulent dire atomicité (tout ou rien) et durabilité (ce qui est validé survit à une panne). Ces notions sont la base d'un registre distribué : une blockchain est d'abord un journal ordonné, partagé et très surveillé, dont l'état est une projection.

Leçons 1/4 · 25 min

Le journal et l'état

Qu'est-ce qui est vrai : le relevé ou le solde ?

Kofi ouvre l'historique de son compte de monnaie mobile : une liste d'opérations datées, et tout en haut un solde de 90 francs. Les deux informations sont exactes, mais elles ne sont pas de même nature. Le solde se déduit de la liste ; la liste ne se déduit pas du solde. Si on ne conservait que « 90 », on ne pourrait ni expliquer pourquoi, ni détecter une erreur, ni retrouver ce qui s'est passé hier. Cette asymétrie est l'idée de tout l'atelier.

Les mots

  • Un événement est un fait qui s'est produit : « crédit de 100 pour Awa », « transfert de 30 d'Awa vers Kofi ». Il a un numéro d'ordre.
  • Le journal (log) est la suite ordonnée des événements. Il est append-only : on ajoute à la fin, on ne modifie ni ne supprime les entrées passées.
  • L'état est le résumé à un instant donné : les soldes, le contenu des comptes. Il est dérivé du journal.
  • La projection (ou rejeu) est le calcul qui passe du journal à l'état : on part d'un état vide et on applique chaque événement dans l'ordre. En formule : état après l'événement k = règle(état après k - 1, événement k).

Un exemple suivi

Deux comptes démarrent à 0 (Awa, Kofi). Voici six événements, avec la règle « un débit ou un transfert qui rendrait un solde négatif est rejeté : sans effet, mais il reste dans le journal ».

N°ÉvénementEffetAwaKofi
1CREDIT Awa 100accepté1000
2CREDIT Kofi 50accepté10050
3TRANSFER Awa → Kofi 30accepté7080
4DEBIT Kofi 100rejeté (80 < 100)7080
5DEBIT Awa 20accepté5080
6CREDIT Kofi 10accepté5090

L'état final est Awa = 50, Kofi = 90. Vérification de cohérence : crédits acceptés 100 + 50 + 10 = 160, débits acceptés 20, 160 - 20 = 140 = 50 + 90. Le transfert ne crée ni ne détruit de l'argent : il le déplace.

Pourquoi garder les deux ?

JournalÉtat
Répond à« que s'est-il passé, dans quel ordre ? »« combien a-t-on maintenant ? »
Coût d'une lecture de soldeO(n) : rejouer tout le journalO(1) : un accès par clé dans un dictionnaire (atelier 102)
Modificationjamais : on ajouteà chaque événement
Si on le perdl'historique est perduon le reconstruit depuis le journal

On peut donc jeter l'état et le recalculer, mais pas l'inverse. C'est la raison pour laquelle les systèmes qui doivent rendre des comptes (banques, registres, blockchains) traitent le journal comme la source de vérité et l'état comme un confort de lecture.

L'approche opposée, qui écrase la valeur en place (« mettre le solde à 90 »), ne conserve aucune trace : une erreur ou une fraude ne laisse rien à examiner.

Ce que cela ne garantit pas

  • Un journal append-only n'est « en ajout seulement » que si le logiciel et les droits d'accès l'imposent. Quelqu'un qui peut écrire dans le fichier peut modifier le passé ; nous le verrons dans l'exercice de sécurité.
  • Le journal peut contenir des événements faux (un crédit qui n'aurait pas dû avoir lieu). Rejouer reconstruit fidèlement ce qui y est écrit, pas ce qui est vrai dans le monde.
  • Si un bogue fait diverger l'état du journal, c'est le journal qui fait foi : on rejoue.

À retenir

Journal = faits ordonnés, ajoutés à la fin. État = résumé dérivé, reconstructible. Un événement rejeté reste au journal. La fiabilité du journal repose sur ce qui l'empêche d'être modifié, pas sur le mot « append-only ».

Leçons 2/4 · 30 min

Rejouer un journal : ordre et déterminisme

Deux ordinateurs, un même journal, deux soldes différents ?

Awa et un auditeur reçoivent chacun une copie du même journal. Si leurs soldes finaux diffèrent, l'un des deux a fait une erreur — ou les règles n'étaient pas les mêmes. Pour que le rejeu serve de vérification, il faut qu'il soit déterministe : mêmes entrées, même sortie, partout et toujours. C'est ce qui permet à n'importe qui de recalculer l'état et d'obtenir le même résultat que tout le monde.

La règle d'application

Voici le cœur d'un rejeu, en Python :

def apply(state, event):
    kind, amount = event[0], event[-1]
    if amount <= 0:
        return False          # montants : entiers strictement positifs seulement
    if kind == "CREDIT":
        _, who, amount = event
        state[who] = state.get(who, 0) + amount
        return True
    if kind == "DEBIT":
        _, who, amount = event
        if state.get(who, 0) < amount:
            return False      # rejeté : aucun effet, reste au journal
        state[who] = state[who] - amount
        return True
    if kind == "TRANSFER":
        _, src, dst, amount = event
        if state.get(src, 0) < amount:
            return False
        state[src] = state[src] - amount
        state[dst] = state.get(dst, 0) + amount
        return True
    return False              # type inconnu : rejeté

def replay(log):
    state, rejected = {}, 0
    for event in log:
        if not apply(state, event):
            rejected += 1
    return state, rejected

Remarquez trois choses. La fonction ne regarde rien d'autre que l'état et l'événement. Le résultat « rejeté » n'est pas une exception : c'est une conséquence normale des règles, qui doit être la même à chaque rejeu. Et les montants sont des entiers strictement positifs (un montant nul, négatif ou un type d'événement inconnu est rejeté, sinon un CREDIT négatif ferait baisser un solde sous zéro) : le franc CFA n'a pas de sous-unité d'usage courant, donc 5 000 francs s'écrivent 5000 ; pour une monnaie à centimes, on stocke de même un entier de la plus petite unité. Avec des nombres à virgule, 0.1 + 0.2 vaut 0.30000000000000004 en Python : de petites erreurs d'arrondi s'accumulent et peuvent dépendre de l'ordre des opérations.

L'ordre fait partie des données

Prenons deux événements : A = DEBIT Awa 30 et B = CREDIT Awa 50, Awa démarrant à 0.

OrdreDéroulementÉtat final d'AwaRejets
A puis BA rejeté (0 < 30), puis +50501
B puis A+50, puis A accepté (50 ≥ 30) → 20200

Les mêmes deux événements donnent des états différents selon l'ordre. Un journal est donc une suite numérotée, pas un ensemble : l'ordre est une donnée, au même titre que les montants. (Le consensus, que vous étudierez plus tard, est précisément la façon dont plusieurs machines s'accordent sur CET ordre.)

Ce qui rend un rejeu non déterministe

  • Lire l'heure ou un nombre aléatoire dans la règle d'application ;
  • utiliser des nombres à virgule ;
  • dépendre de l'ordre d'itération d'une structure non ordonnée ;
  • appliquer une version différente des règles à une partie du journal : les règles font partie de l'identité de l'état. Si l'on change la règle de rejet, un ancien journal ne donne plus les mêmes soldes.

Vérifier un état

Un état reconstruit se vérifie de deux façons. D'abord, en le recalculant depuis le journal et en comparant. Ensuite, par des invariants : des propriétés qui doivent toujours tenir. Ici, la somme des soldes est égale aux crédits acceptés moins les débits acceptés (un transfert ne la change pas), et aucun solde n'est négatif. Si un invariant est violé après un rejeu, il y a un bogue dans les règles ou une anomalie dans le journal.

Instantanés : éviter de tout rejouer

Rejouer un million d'événements pour lire un solde est lent. Un instantané (snapshot) est une copie de l'état après l'événement k. Pour obtenir l'état actuel, on repart de l'instantané et on ne rejoue que les événements k + 1, k + 2, … Avec un instantané après 900 000 événements dans un journal de 1 000 000, on rejoue 100 000 événements. L'instantané est un cache : il doit toujours pouvoir être comparé au rejeu complet (c'est l'objet du laboratoire autonome).

Ce que cela ne garantit pas

Rejouer prouve que l'état est cohérent avec le journal, pas que le journal est exact. Si un événement a été falsifié avant le rejeu, le rejeu reconstruit fidèlement le mensonge. Savoir que le journal n'a pas été modifié demande un mécanisme de plus (exercice de sécurité).

À retenir

Un rejeu est une fonction de (état, événement) vers un nouvel état, sans hasard ni horloge ; l'ordre des événements fait partie des données ; on compte en entiers ; on vérifie par recalcul et par invariants ; un instantané évite de tout rejouer.

Leçons 3/4 · 30 min

Une base de données : tables, requêtes, index

Pourquoi pas simplement un fichier ?

On pourrait écrire le journal dans un fichier texte. Mais dès que plusieurs programmes lisent et écrivent, qu'il faut chercher vite et qu'une panne ne doit rien abîmer, on réinvente à la main ce que fournit une base de données : un stockage organisé, un langage de requêtes, des index et des garanties en cas de panne. SQLite est une base complète contenue dans un seul fichier, sans serveur à installer ; Python la pilote avec son module sqlite3.

Tables, lignes, requêtes

Une table est un tableau : chaque ligne est un enregistrement, chaque colonne un champ. Le langage SQL décrit ce qu'on veut sans dire comment le calculer.

CREATE TABLE events (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  kind         TEXT    NOT NULL CHECK (kind IN ('CREDIT', 'DEBIT', 'TRANSFER')),
  account      TEXT    NOT NULL,   -- compte crédité (CREDIT), débité (DEBIT, TRANSFER)
  counterparty TEXT,               -- bénéficiaire, seulement pour TRANSFER
  amount       INTEGER NOT NULL CHECK (amount > 0)
);

INSERT INTO events (kind, account, amount) VALUES
  ('CREDIT', 'awa',  100),
  ('CREDIT', 'kofi',  50),
  ('DEBIT',  'awa',   30);

SELECT account,
       SUM(CASE kind WHEN 'CREDIT' THEN amount ELSE -amount END) AS balance
FROM events
WHERE kind IN ('CREDIT', 'DEBIT')
GROUP BY account;

Résultat de la dernière requête : awa | 70 et kofi | 50. C'est exactement la projection de la leçon 2, exprimée en SQL : le journal est la table events, le solde est un calcul sur elle. Les contraintes CHECK refusent d'office un type inconnu ou un montant nul ou négatif ; PRIMARY KEY AUTOINCREMENT donne à chaque ligne un numéro d'ordre croissant. Comme dans la leçon 1, un transfert est un événement (TRANSFER, avec le bénéficiaire dans counterparty) ; cette requête ne le compte pas (il faudrait débiter account et créditer counterparty), c'est pourquoi l'état est aussi gardé dans une table à part, plus bas.

Les index : réutiliser l'atelier 102

Chercher les événements d'un compte avec WHERE account = 'awa' sans index oblige à parcourir toute la table : O(n). Un index est une structure supplémentaire, maintenue par la base, qui retrouve les lignes en O(log n) : en SQLite, les tables et les index sont rangés dans des arbres B, les arbres équilibrés à nombreux enfants évoqués à l'atelier 102.

CREATE INDEX idx_events_account ON events (account);

Le prix : chaque insertion doit aussi mettre à jour l'index, et il occupe de la place. On indexe ce qu'on cherche souvent.

Garder l'état à côté du journal

On peut conserver, dans une seconde table, un état déjà calculé :

CREATE TABLE balances (
  account TEXT PRIMARY KEY,
  amount  INTEGER NOT NULL CHECK (amount >= 0)
);

La contrainte CHECK (amount >= 0) fait refuser par la base toute opération qui rendrait un solde négatif : le UPDATE fautif échoue, donc (leçon 4) toute la transaction est annulée. C'est un second garde-fou pour la règle de rejet. Lire un solde devient un accès par clé, O(log n) (ou O(1) en mémoire dans un dictionnaire).

Un journal qui refuse les modifications

On peut demander à SQLite d'interdire la modification et la suppression des lignes de events :

CREATE TRIGGER events_no_update BEFORE UPDATE ON events
BEGIN
  SELECT RAISE(ABORT, 'events is append-only');
END;

(Un déclencheur analogue BEFORE DELETE ferme l'autre porte.) C'est utile contre les erreurs et les bogues. Mais quelqu'un qui contrôle le fichier ou les droits d'administration peut supprimer le déclencheur ou réécrire le fichier : ce n'est pas une garantie cryptographique.

Ce que cela ne garantit pas

  • Une base de données n'est pas « vraie » : elle conserve ce qu'on lui a donné, y compris des erreurs.
  • Un index accélère certaines recherches et ralentit les écritures ; sans mesure (atelier 102), on ne sait pas s'il vaut son coût.
  • SQL n'est pas magique : la requête de solde ci-dessus n'applique aucune règle métier.

À retenir

Table = journal ; requête = projection ; index = arbre qui accélère la recherche ; contraintes et déclencheurs = garde-fous, pas preuves. Les règles du registre se vérifient à l'ajout ou au rejeu.

Leçons 4/4 · 30 min

Atomicité et durabilité : survivre à une panne

Le transfert qui disparaît

Awa envoie 5 000 francs à Kofi. Dans notre registre, c'est un événement (TRANSFER), mais il demande trois écritures dans la base : ajouter l'événement au journal, retirer 5 000 du solde d'Awa, ajouter 5 000 au solde de Kofi. Le programme écrit les deux premières… et la batterie lâche. Au redémarrage, Awa a perdu 5 000 francs que Kofi n'a jamais reçus : l'argent a été détruit. (Dans l'autre sens, avec le crédit de Kofi écrit sans le débit d'Awa, il aurait été créé.) Le problème n'est pas une erreur de calcul, c'est que des écritures qui devaient aller ensemble ont été séparées.

Atomicité : tout ou rien

Une transaction regroupe plusieurs opérations en une seule unité atomique : toutes sont appliquées, ou aucune. Il n'existe pas de résultat intermédiaire visible après une panne.

BEGIN;
INSERT INTO events (kind, account, counterparty, amount) VALUES ('TRANSFER', 'awa', 'kofi', 5000);
UPDATE balances SET amount = amount - 5000 WHERE account = 'awa';
UPDATE balances SET amount = amount + 5000 WHERE account = 'kofi';
COMMIT;

Tant que COMMIT n'a pas réussi, rien n'est définitivement écrit ; en cas d'erreur, ROLLBACK (ou la panne elle-même) annule tout. Si Awa n'avait que 3 000 francs, la contrainte CHECK (amount >= 0) de la table balances ferait échouer le premier UPDATE : la transaction entière serait annulée, et le transfert refusé ne serait écrit nulle part, ni dans events ni dans balances.

La règle se vérifie dans la transaction

La règle « pas de solde négatif » doit être vérifiée au moment de l'ajout, dans la même transaction que les écritures. Sinon, deux demandes simultanées peuvent lire le même solde avant que l'une des deux n'écrive, et dépenser deux fois la même somme. Voici le code complet en Python :

import sqlite3
# isolation_level=None : nous écrivons nous-mêmes BEGIN / COMMIT / ROLLBACK
conn = sqlite3.connect("ledger.db", isolation_level=None)

def transfer(conn, src, dst, amount):
    if amount <= 0:
        raise ValueError("montant invalide")
    conn.execute("BEGIN IMMEDIATE")   # verrou d'écriture pris AVANT de lire le solde
    try:
        row = conn.execute("SELECT amount FROM balances WHERE account = ?", (src,)).fetchone()
        if row is None or row[0] < amount:
            conn.execute("ROLLBACK")
            return False              # refusé : rien n'est écrit
        conn.execute("INSERT INTO events (kind, account, counterparty, amount) "
                     "VALUES ('TRANSFER', ?, ?, ?)", (src, dst, amount))
        conn.execute("UPDATE balances SET amount = amount - ? WHERE account = ?", (amount, src))
        cur = conn.execute("UPDATE balances SET amount = amount + ? WHERE account = ?", (amount, dst))
        if cur.rowcount != 1:
            raise ValueError("compte destinataire inconnu")   # sinon l'argent disparaîtrait
        conn.execute("COMMIT")
        return True
    except Exception:
        conn.execute("ROLLBACK")      # annule les trois écritures
        raise

BEGIN IMMEDIATE prend le verrou d'écriture de SQLite avant la lecture du solde : un second transfert simultané attend, ou échoue avec une erreur de base verrouillée, au lieu de lire le même solde en même temps. Cette question d'isolation est l'une des lettres d'ACID, citée plus bas pour mémoire.

Python offre aussi un raccourci : avec le mode par défaut du module sqlite3 (connexion ouverte sans isolation_level=None), un bloc with conn: valide la transaction si le bloc se termine bien et l'annule si une exception en sort (il ne ferme pas la connexion).

conn = sqlite3.connect("ledger.db")
with conn:    # valide si le bloc se termine bien, annule si une exception en sort
    conn.execute("INSERT INTO events (kind, account, counterparty, amount) VALUES ('TRANSFER', 'awa', 'kofi', 5000)")
    conn.execute("UPDATE balances SET amount = amount - 5000 WHERE account = 'awa'")
    conn.execute("UPDATE balances SET amount = amount + 5000 WHERE account = 'kofi'")

Ce raccourci ne vérifie pas le solde : la règle de rejet reste à écrire dans le bloc.

Durabilité : ce qui est validé survit

Une fois que COMMIT a répondu « réussi », les données survivent à une coupure de courant ou à un plantage du programme. Concrètement, avec les réglages par défaut, SQLite consigne d'abord dans un fichier de reprise de quoi défaire la transaction (le rollback journal garde l'ancien contenu des pages modifiées) ou la refaire (le fichier WAL, write-ahead log, ajoute le nouveau contenu), force ce fichier à atteindre le disque (opération dite fsync), et seulement alors annonce la réussite. Au redémarrage, la base lit ce fichier et remet les choses dans un état où chaque transaction est soit entière, soit absente.

Que trouve-t-on après une panne ?

La panne survient…Au redémarrage
avant BEGINrien n'a eu lieu
après les premières écritures, avant COMMITni l'événement ni les soldes modifiés (tout annulé)
pendant le COMMITtout, ou rien — jamais une partie
après que COMMIT a répondutout (durabilité)

La ligne du milieu est l'atomicité ; la dernière est la durabilité.

Le coût de la durabilité

Attendre que le disque confirme prend du temps : chaque validation durable est coûteuse. Grouper beaucoup d'écritures dans une seule transaction est donc souvent bien plus rapide que de valider chaque ligne séparément (à vérifier par mesure, comme à l'atelier 102). SQLite permet de régler ce compromis avec PRAGMA synchronous ; en le baissant, on gagne en vitesse mais on accepte qu'une coupure de courant puisse faire perdre des validations récentes ; selon le mode choisi, la base elle-même peut même être abîmée. Les bons réglages dépendent de ce que coûte une perte.

Les autres lettres d'ACID

On résume souvent ces garanties par l'acronyme ACID : Atomicité, Cohérence, Isolation, Durabilité. La cohérence (les règles de la base, comme CHECK, restent vraies) et l'isolation (des transactions simultanées ne se gênent pas) sont citées ici pour mémoire ; elles ne font pas partie de cet atelier.

Ce que cela ne garantit pas

  • Durable ne veut pas dire sauvegardé. Si le disque meurt, la base meurt avec lui. Pour survivre à cela, il faut des copies ailleurs.
  • L'atomicité vaut à l'intérieur d'une base. Si le débit est dans la base de votre opérateur et le crédit dans celle d'une autre banque, aucune transaction SQLite ne les unit : il faut des protocoles de coordination, que vous rencontrerez avec les systèmes distribués.
  • Une transaction atomique peut contenir une règle métier fausse : elle garantit que le résultat est écrit en entier, pas qu'il est juste.
  • La durabilité suppose que le matériel respecte les demandes d'écriture ; certains disques mentent sur ce point.

À retenir

Atomicité : un groupe d'écritures est appliqué en entier ou pas du tout. Durabilité : une validation réussie survit à une panne, grâce à un fichier de reprise écrit avant la réponse. Ni l'une ni l'autre ne protège d'un disque détruit, d'une règle erronée, ni d'un journal falsifié par quelqu'un qui contrôle la base.

Laboratoire guidé

Le navigateur ne lance ni Python ni SQLite : ce laboratoire vous fait faire à la main, sur des données générées pour vous, ce que le code ferait — rejouer un journal, lire une requête, raisonner sur une panne. Le registre réel que vous construirez est le laboratoire autonome. Les épreuves générées changent à chaque tentative ; faites vos calculs sur papier, ligne par ligne, comme dans le tableau de la leçon 1.

Le navigateur ne remplace pas un vrai terminal : ce laboratoire entraîne la lecture et le raisonnement ; la pratique réelle est dans le laboratoire autonome.

Un compte certifié est nécessaire

Les activités notées enregistrent des résultats à votre nom et servent de base à un titre. Elles exigent donc un dossier certifiant complet et un consentement en vigueur. Remplir le dossier ne vérifie pas votre identité ; le Secrétariat peut ensuite la vérifier (badge « Vérifié »).

  1. 1.Rejouer un journal

    L'épreuve ci-dessous affiche un journal de plus de dix événements pour trois comptes qui démarrent à 0. Rejouez-le dans l'ordre, en appliquant la règle : un DEBIT ou un TRANSFER qui rendrait le solde négatif est REJETÉ (sans effet, mais il reste au journal). Tenez un petit tableau de trois colonnes à jour après chaque événement. Donnez le solde demandé, le nombre d'événements rejetés et la somme des trois soldes. Contrôle : la somme doit égaler les crédits acceptés moins les débits acceptés ; si ce n'est pas le cas, vous avez fait une erreur de report.

  2. 2.L'ordre change l'état

    Trois événements concernent le seul compte d'Awa, qui démarre à 0 : E1 = CREDIT Awa 40, E2 = DEBIT Awa 60, E3 = CREDIT Awa 30. Rejouez-les d'abord dans l'ordre E1, E2, E3, puis dans l'ordre E1, E3, E2, en appliquant la règle de rejet de la leçon 2. Notez à chaque fois le solde final et le nombre de rejets, puis répondez à la question posée.

  3. 3.Lire une requête SQL

    La table events contient, dans l'ordre : (1, CREDIT, awa, 200), (2, CREDIT, kofi, 80), (3, DEBIT, awa, 50), (4, CREDIT, awa, 35), (5, DEBIT, kofi, 20), (6, DEBIT, awa, 10), sous la forme (id, kind, account, amount). On exécute : SELECT SUM(CASE kind WHEN 'CREDIT' THEN amount ELSE -amount END) FROM events WHERE account = 'awa'; Calculez à la main ce que la requête renvoie, comme la projection de la leçon 3, puis répondez à la question posée.

  4. 4.Raisonner sur une panne

    Le programme de la leçon 4 enregistre un transfert de 5 000 francs en une seule transaction de trois écritures : un INSERT de l'événement TRANSFER dans events, puis deux UPDATE dans balances (le débit d'Awa, puis le crédit de Kofi). Placez sur une ligne de temps : BEGIN, INSERT, UPDATE débit, UPDATE crédit, COMMIT, réponse « réussi » au programme. Pour chaque point où la machine peut s'éteindre, dites ce que contiennent les tables events et balances au redémarrage. Répondez ensuite à la question posée, qui porte sur une panne à un point précis.

Exercice de sécurité — Altérer une entrée du journal : ce qui ne le détecte pas

Le registre d'une petite coopérative

Une coopérative tient son registre dans SQLite, comme dans la leçon 3 : une table events (journal) et une table balances (état), mise à jour par le programme à chaque ajout. Un déclencheur BEFORE UPDATE interdit de modifier les lignes d'events. Voici les six premières lignes, puis les soldes :

id  kind    account  amount
1   CREDIT  awa      200
2   CREDIT  kofi      80
3   CREDIT  awa      500      <- ligne ciblée
4   DEBIT   awa       50
5   CREDIT  kofi      35
6   DEBIT   kofi      20

balances : awa = 650, kofi = 95

(La colonne counterparty, vide pour ces lignes, n'est pas affichée.)

Un administrateur malhonnête, qui a accès au fichier de la base, effectue l'opération suivante en un instant : il supprime le déclencheur, remplace le montant de la ligne 3 (500) par 50, remet le déclencheur, puis relance le calcul de la table balances à partir du journal. Les soldes deviennent awa = 200, kofi = 95. Aucune erreur n'est signalée.

Ce que l'exercice montre

La colonne id est un simple numéro ; UNIQUE ou PRIMARY KEY garantissent seulement qu'il n'y a pas de doublon. Rien dans une ligne n'engage les lignes précédentes : modifier la ligne 3 ne laisse aucune trace dans les lignes 1, 2, 4, 5, 6. Le rejeu du journal ne le révélera pas non plus : il reconstruit fidèlement le journal modifié.

Ce qui permettrait de le détecter, sans encore de cryptographie, c'est une référence extérieure : par exemple un relevé des soldes finaux envoyé par courrier à un auditeur la veille, ou une copie du journal stockée ailleurs et comparée. Le mécanisme qui rend la modification détectable dans le journal lui-même — faire dépendre chaque entrée de la précédente par une empreinte — est l'objet de l'atelier 104.

À faire

Répondez à la question de contrôle : parmi les affirmations proposées, lesquelles sont vraies pour ce registre ?

Questionnaire de l'atelier

Huit questions tirées d'une réserve plus large, seuil de réussite 70 %. Il s'ouvre quand tous les points de contrôle du laboratoire sont réussis. Vous pouvez le repasser : le tirage change.

Un compte certifié est nécessaire

Les activités notées enregistrent des résultats à votre nom et servent de base à un titre. Elles exigent donc un dossier certifiant complet et un consentement en vigueur. Remplir le dossier ne vérifie pas votre identité ; le Secrétariat peut ensuite la vérifier (badge « Vérifié »).

Laboratoire autonome (hors du site)

Non noté en ligne : à réaliser sur votre machine et à publier sur votre dépôt Git.

Laboratoire autonome : un registre append-only avec instantanés

À faire sur votre machine, avec Python 3 et son module sqlite3 (aucune installation).

Partie 1 : le journal. Créez une base ledger.db avec la table events de la leçon 3 (colonnes id, kind, account, counterparty, amount), la table balances de la même leçon, plus un déclencheur interdisant UPDATE et DELETE sur events. Écrivez une fonction append(kind, account, amount) qui ajoute un événement dans une transaction.

Partie 2 : les règles et le rejeu. Écrivez replay(conn) qui lit les événements par ordre d'id et reconstruit un dictionnaire de soldes en appliquant la règle de rejet de la leçon 2 (les événements rejetés restent dans le journal). Écrivez les tests : un scénario avec un rejet, un scénario où l'ordre change le résultat, et un contrôle d'invariant (somme des soldes = crédits acceptés - débits acceptés).

Partie 3 : atomicité. Reprenez la fonction transfer(src, dst, amount) de la leçon 4 (un événement TRANSFER et deux mises à jour de balances dans une seule transaction, avec vérification du solde avant d'écrire). Simulez une panne en levant une exception entre deux écritures (par exemple raise RuntimeError après le premier UPDATE) et vérifiez, en rouvrant la base, que rien n'a été conservé. Refaites le test sans transaction : ouvrez la connexion en mode autocommit (sqlite3.connect('ledger.db', isolation_level=None) et n'écrivez ni BEGIN ni COMMIT, ou autocommit=True depuis Python 3.12), de sorte que chaque execute soit validé immédiatement ; l'exception laisse alors l'événement et le débit sans le crédit. Avec le mode par défaut du module, le premier INSERT/UPDATE ouvre une transaction implicite et une fermeture sans commit() annulerait tout : vous ne verriez aucune différence, et vous pourriez croire à tort que l'atomicité est automatique.

Partie 4 : instantanés. Ajoutez une table snapshots(last_id, account, amount). Écrivez take_snapshot() et state_from_snapshot() qui part du dernier instantané et ne rejoue que les événements suivants. Démontrez par test que l'état obtenu est identique à celui du rejeu complet, sur au moins 1 000 événements aléatoires (utilisez un germe fixe pour que le test soit reproductible). Mesurez le gain de temps, comme à l'atelier 102.

Partie 5 : altération (sur votre propre fichier seulement). Modifiez à la main une ligne intermédiaire de votre ledger.db (en supprimant d'abord le déclencheur). Que détecte votre programme ? Que ne détecte-t-il pas ? Écrivez trois lignes qui expliquent pourquoi, avec les mots de l'exercice de sécurité.

À rendre. Le code, les tests, et une note d'une page qui explique la relation journal / état, ce que garantissent atomicité et durabilité dans votre programme, et ce qu'ils ne garantissent pas.

À retenir

  • Le journal (événements ordonnés, ajoutés à la fin) est la source de vérité ; l'état (les soldes) en est une projection que l'on peut jeter et reconstruire.
  • Un rejeu est déterministe : même journal, mêmes règles, même état. L'ordre des événements fait partie des données et les montants se comptent en entiers.
  • Un événement rejeté reste au journal ; l'état se vérifie par recalcul et par invariants, et un instantané évite de tout rejouer.
  • Atomicité : un groupe d'écritures passe en entier ou pas du tout. Durabilité : ce qui est validé survit à une panne, grâce à un fichier de reprise écrit avant la réponse.
  • Ni la base ni le rejeu ne prouvent que le journal est exact : sans chaînage cryptographique ni référence extérieure, une entrée modifiée n'est pas détectée.

Idées fausses courantes

  • Idée reçue : Il suffit de stocker le solde : le journal est du gaspillage.

    En réalité : Sans journal, on ne peut ni expliquer un solde, ni détecter une erreur, ni reconstruire l'état après un bogue. Le solde se déduit du journal, pas l'inverse.

  • Idée reçue : « Append-only » signifie que personne ne peut modifier le passé.

    En réalité : C'est une règle que le logiciel suit, pas une loi physique. Quelqu'un qui contrôle la base ou le fichier peut la contourner ; sans empreintes chaînées ou copie extérieure, on ne le voit pas.

  • Idée reçue : Une fois la transaction validée, mes données sont à l'abri de tout.

    En réalité : La durabilité protège d'une coupure de courant ou d'un plantage, pas d'un disque détruit, d'un opérateur malhonnête, ni d'une règle métier erronée. Les sauvegardes sont un mécanisme distinct.

  • Idée reçue : Les mêmes événements donnent toujours le même état, quel que soit l'ordre.

    En réalité : Avec une règle de rejet (pas de solde négatif), un débit placé avant le crédit qui le finance est rejeté, alors qu'il est accepté après. L'ordre fait partie des données.

Glossaire

Événement
Fait qui s'est produit (crédit, débit, transfert), enregistré avec un numéro d'ordre dans le journal.
Journal (log)
Suite ordonnée d'événements, à laquelle on ajoute à la fin sans modifier le passé.
État
Résumé du système à un instant donné (par exemple les soldes), dérivé du journal.
Rejeu (projection)
Calcul qui part d'un état vide et applique les événements dans l'ordre pour reconstruire l'état.
Déterminisme
Propriété d'un calcul qui donne toujours le même résultat pour les mêmes entrées, sans hasard ni horloge.
Instantané (snapshot)
Copie de l'état après un certain événement, qui évite de rejouer tout le journal.
Transaction
Groupe d'opérations traité comme une unité : validé en entier avec COMMIT ou annulé avec ROLLBACK.
Atomicité
Garantie qu'une transaction est appliquée en entier ou pas du tout, même en cas de panne.
Durabilité
Garantie qu'une transaction validée survit à une panne ou une coupure de courant.
Index
Structure supplémentaire (en SQLite, un arbre B) qui accélère la recherche de lignes au prix d'écritures plus lentes.