dimesum

Accueil / Blog / Ingénierie

Ingénierie

Le grand livre append-only qui garde les soldes exacts

· 9 min de lecture ·

Un solde doit être une projection que l'on peut jeter et reconstruire, et voici la mécanique qui rend cela sûr : écritures à somme nulle, modifications qui inscrivent des contre-passations, et un outbox qui expédie dans l'ordre du commit.

Un solde que l'on incrémente est un solde qui dérive. Dimesum n'en incrémente jamais. Chaque dépense inscrit une écriture en partie double dont les lignes totalisent exactement zéro, et le nombre affiché sur l'écran de votre groupe est une projection de ces lignes que nous pouvons supprimer et reconstruire avec une seule commande.

Cette affirmation est vérifiable. Lorsque l'argent ne bouge que par une écriture append-only, un solde faux cesse d'être un mystère et devient une requête que nous pouvons exécuter.

Les soldes sont une projection, pas un nombre que l'on incrémente

Le grand livre de Dimesum possède trois tables : journals, postings et une projection balances indexée par groupe, membre et devise. Les lignes portent la vérité. Une ligne est positive quand un membre a mis de l'argent dans le groupe et négative quand il a consommé de la valeur, si bien qu'un solde est un simple SUM(amount_minor). La projection existe pour la vitesse, pas pour l'autorité : elle est écrite dans la transaction de l'écriture elle-même, et le fait que les lignes soient complètes la garde jetable.

Deux couches affirment le même zéro, et aucune ne suffit seule

En Go, buildPostings refuse d'ouvrir une transaction tant que l'ensemble des lignes ne totalise pas zéro. Dans Postgres, un trigger de contrainte différée revérifie SUM(amount_minor) = 0 par écriture au commit, parce que les lignes s'insèrent une à une et qu'une vérification ligne par ligne rejetterait la première ligne de chaque écriture. L'assertion attrape un bug dans le calculateur. Le trigger attrape un writer qui ne l'a jamais appelé.

Une troisième couche est un privilège. UPDATE, DELETE et TRUNCATE sont révoqués du rôle ledger_app sur les deux tables, de sorte qu'un code défectueux ne peut pas réécrire l'histoire même quand il essaie.

Ce qu'un grand livre append-only rend impossible, face à une table de soldes incrémentée
Classe de bugTable de soldes incrémentéeGrand livre append-only
Débiteur débité, payeur jamais créditéFaux en silence pour toujoursLa vérification à somme nulle échoue, écriture rejetée
Une part change, le total nonLes soldes dérivent en silenceL'écriture ne peut pas être validée si elle est déséquilibrée
« Pourquoi mon solde est-il de 412 € ? »Sans réponse possibleChaque centime remonte à une écriture
Un correctif à chaud en productionMutation non tracéeLe seul chemin est une nouvelle écriture auditée

Une modification inscrit une contre-passation, jamais un UPDATE

Modifier une dépense n'écrit rien par-dessus l'ancienne version. Le grand livre consomme un événement expense.amended et inscrit deux écritures dans une seule transaction : un EXPENSE_REVERSAL dont les lignes sont la négation exacte, ligne pour ligne, de la version remplacée, puis un nouvel EXPENSE pour la nouvelle. Une suppression s'arrête après la contre-passation.

La transaction unique compte autant que les deux écritures. Si la contre-passation était validée seule, un groupe ne devrait brièvement rien pour une dépense qu'il doit encore.

La modification d'une dépense inscrit un EXPENSE_REVERSAL qui annule la version un, plus un nouvel EXPENSE pour la version deux, dans une seule transaction ; la projection des soldes est une somme sur toutes les lignes. une transaction EXPENSE expense:7c1:v1 4 lignes somme = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal chaque ligne annulée somme = 0 EXPENSE expense:7c1:v2 5 lignes somme = 0 projection des soldes = SUM(postings) par membre, par devise jetable : evenly rebuild-balances la vide et rejoue chaque ligne
Une modification ajoute : une contre-passation nomme la version qu'elle annule, et le remplacement est inscrit à côté.

Les deux clés d'idempotence sont dérivées de la version plutôt que forgées : expense:<id>:v<n> pour la version, et la clé de la version précédente suivie de :reversal pour la négation. journals.idempotency_key est UNIQUE, si bien qu'un événement redélivré retrouve ses écritures déjà présentes et ne fait rien. C'est la dérivation qui rend la livraison au moins une fois sûre : une nouvelle tentative calcule la clé qu'a utilisée la première.

Un filigrane d'ordre de commit garde une modification derrière sa dépense

Chaque événement que Dimesum publie est écrit dans une table outbox dans la même transaction que l'écriture métier. Si la dépense est validée, l'annonce existe. Si elle est annulée, l'annonce l'est aussi. Le grand livre n'entend jamais parler d'une dépense qui n'existe pas.

L'ordre d'expédition est la moitié la plus difficile. Les identifiants sont des UUIDv7 et se trient par le temps, mais ils encodent le moment où l'identifiant a été forgé, pas le moment où sa transaction a été validée. Un relais qui lit dans l'ordre des identifiants peut dépasser une transaction qui a pris un identifiant antérieur et validé plus tard, de sorte qu'une modification double la dépense qu'elle modifie.

L'écriture métier et sa ligne d'outbox sont validées dans une seule transaction ; le relais n'expédie ensuite que les lignes d'outbox dont l'identifiant de transaction inséré est sous le filigrane pg_snapshot_xmin, dans l'ordre des identifiants de transaction. une transaction INSERT ligne expense INSERT ligne outbox topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 writer en vol bus d'événements grand livre consommateur pg_snapshot_xmin(pg_current_snapshot()). Les lignes au-dessus ont été écrites par des transactions qui ont été validées sans qu'aucune plus ancienne ne tourne encore. La ligne du dessous attend la passe suivante. Trier par identifiant expédierait 019a-1a8 en premier, si bien qu'une modification pourrait arriver avant la dépense qu'elle modifie. Trier par inserted_xid ne le peut pas : un événement plus tardif a un xid plus tardif.
La ligne d'outbox est validée avec l'écriture métier, et le relais expédie sous le filigrane dans l'ordre des identifiants de transaction.

Le correctif tient en une colonne et un prédicat. Chaque ligne d'outbox porte inserted_xid xid8 DEFAULT pg_current_xact_id(), et le relais ne lit que les lignes WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), triées par ce xid (voir les fonctions d'identifiant de transaction de PostgreSQL). Un événement causalement plus tardif porte toujours un xid plus tardif, car il a dû lire la ligne antérieure pour exister.

Le consommateur de modifications ne prend pas cet ordre pour argent comptant. Une modification dont le prédécesseur n'a pas d'écriture est redélivrée tant qu'elle est jeune, et mise de côté pour un humain une fois passé un délai de grâce de deux minutes.

L'argent est en unités mineures int64, et l'exposant n'est pas toujours deux

Chaque montant est un décompte int64 d'unités mineures plus un code ISO 4217, si bien que 1 234,56 roupies s'écrit {Minor: 123456, Currency: "INR"}. Les entiers sont exacts par construction, pas par discipline : aucune valeur représentable ne vaut un demi-centime, donc aucune opération ne peut en produire un en silence. Le sidecar Python lit son propre AST et fait échouer un test si le mot float apparaît dans son module de montants.

Supposer que l'unité mineure vaut un centième est le piège sous-jacent. Le JPY n'en a aucune et le KWD a trois décimales, donc un taux coté entre unités majeures et appliqué à des unités mineures se trompe d'une puissance de dix. Prenez 2 000 yens à 0,58 : le produit naïf est 1160, qui se lit 11,60 roupies, alors que la réponse est 1 160.

WRITE_OFF est un cinquième type d'écriture parce que ses lignes correspondent à celles d'un règlement

Une remise inscrit les deux mêmes lignes qu'un règlement : le débiteur monte, le créancier descend, du même montant. La fondre dans SETTLEMENT a été rejeté pour la raison précise qui fait que les formes coïncident. « Asha vous a payé 500 euros » et « vous avez remis 500 euros à Asha » sont des faits différents, et un flux qui les confondrait dirait que quelqu'un a payé alors que personne ne l'a fait.

C'est ainsi que WRITE_OFF a rejoint le CHECK des types d'écriture le 2026-08-21, avec ses propres lignes. Les nommer était la moitié de l'enjeu, car une remise réutilisant SETTLE_PAY rendrait fausse en silence toute requête « combien a réellement été payé ».

Les cinq types d'écriture du grand livre Dimesum et les lignes que chacun inscrit
Type d'écritureInscrit quandLignes
EXPENSECréée, ou une nouvelle version en remplace unePAID, SHARE
EXPENSE_REVERSALModifiée ou suppriméeLes lignes de l'écriture précédente, annulées
SETTLEMENTUn remboursement est déclaréSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALLa contrepartie le contesteLes deux lignes de règlement, annulées
WRITE_OFFUn créancier renonce à une créanceWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

Deux sous-commandes transforment les invariants en tâche cron

evenly verify-ledger reprouve les invariants sur tout le schéma et sort avec un code non nul à la moindre anomalie. Son rapport a quatre champs, et chacun doit être vide : les écritures qui ne totalisent pas zéro, les groupes qui ne totalisent pas zéro, les lignes de projection qui divergent d'un SUM(postings) recalculé, et les événements mis de côté en attente d'un humain. Tous les balayages partagent un même snapshot repeatable-read, de sorte qu'une écriture arrivant en plein verify ne peut pas fabriquer un écart.

Chaque balayage regroupe par devise autant que par identifiant, et l'échec qu'il évite est un faux négatif. Une ligne de 500 roupies et une de moins 500 yens totalisent zéro quand une requête ignore la colonne de devise, si bien qu'un grand livre doublement corrompu paraîtrait sain.

evenly rebuild-balances [group-id] est la réparation et l'exercice. Elle vide la projection et recalcule chaque ligne à partir des lignes d'écriture, estampillant chacune avec la dernière écriture qui a bougé ce membre, comme le fait l'écriture en direct. L'égalité ligne pour ligne est la propriété : quand une reconstruction et la projection en direct divergent, c'est le grand livre qui a raison.

Exécutez-les dans cet ordre

Vérifiez, puis reconstruisez. Le rapport nomme le solde stocké et le solde recalculé pour chaque ligne qui dérive, et une reconstruction écrase la valeur stockée, si bien que reconstruire d'abord détruit la preuve.

Rendez le solde dérivable et la dérive devient une requête

Un grand livre append-only ne mérite sa deuxième écriture que si vous pouvez le prouver, alors construisez le vérificateur et la reconstruction avant la fonctionnalité qui en a besoin. Une reconstruction que personne n'a jamais exécutée est un espoir, pas une issue de secours. Choisissez cette semaine votre projection d'argent la plus risquée, écrivez la requête qui la recalcule depuis la source, et faites-vous alerter quand les deux divergent.

Questions fréquentes

Qu'est-ce qu'un grand livre append-only dans une appli de partage de dépenses ?

Un grand livre append-only enregistre chaque événement d'argent comme une écriture de lignes qui totalisent zéro, et n'en met jamais à jour ni ne le supprime. Dans Dimesum, une dépense, une modification, un règlement, une contestation et une remise ajoutent chacun une nouvelle écriture. Les soldes sont ensuite dérivés en sommant les lignes, si bien que chaque centime remonte à l'événement qui l'a déplacé.

Comment un grand livre append-only gère-t-il une dépense modifiée ?

Une modification inscrit deux écritures dans une transaction : un EXPENSE_REVERSAL qui annule l'original ligne pour ligne, puis un nouvel EXPENSE portant la version de remplacement. Rien n'est réécrit, et une suppression n'inscrit que la contre-passation. Les deux écritures prennent des clés d'idempotence dérivées de la version de la dépense, si bien qu'un événement redélivré les retrouve déjà présentes et ne change rien.

Pourquoi stocker l'argent en entiers plutôt qu'en flottants ?

Les unités mineures entières sont exactes par construction, alors que le flottant binaire ne peut pas représenter 0,1 exactement et dérive au fil de l'historique d'un groupe. Dimesum stocke chaque montant comme un décompte int64 d'unités mineures plus un code ISO 4217. L'exposant vient de la devise : le JPY n'a aucune unité mineure, donc supposer un centième est une erreur d'un facteur 100.

Comment savoir si les soldes de dépenses partagées n'ont pas dérivé ?

Exécutez evenly verify-ledger, qui reprouve les invariants sur tout le schéma et sort avec un code non nul à la moindre anomalie. Il signale les écritures qui ne totalisent pas zéro, les groupes qui ne totalisent pas zéro, les lignes de projection en désaccord avec un SUM(postings) recalculé, et les événements mis de côté. Mettez-le en cron chaque nuit, et réparez une projection erronée avec evenly rebuild-balances.

Pourquoi une remise a-t-elle besoin de son propre type d'écriture ?

Une remise inscrit des lignes identiques à celles d'un règlement, ce qui est précisément pourquoi le type d'écriture devait différer. Seul ce mot sépare « Asha vous a payé 500 euros » de « vous avez remis 500 euros à Asha », et un flux qui les confondrait dirait que quelqu'un a payé alors que personne ne l'a fait. Ses lignes s'appellent WRITE_OFF_FORGIVEN et WRITE_OFF_GRANTED pour que les requêtes de paiement restent correctes.