dimesum

Home / Blog / Techniek

Techniek

Het append-only grootboek dat saldi exact houdt

· 8 min leestijd ·

Een saldo hoort een projectie te zijn die je kunt weggooien en opnieuw opbouwen, en dit is de machinerie die dat veilig maakt: nulsomjournalen, bewerkingen die correcties boeken, en een outbox die op commitvolgorde verzendt

Een saldo dat je ophoogt, is een saldo dat afwijkt. Dimesum hoogt er nooit een op. Elke uitgave boekt een journaal met dubbele boeking waarvan de posten optellen tot precies nul, en het getal op je groepsscherm is een projectie over die posten die we met één commando kunnen verwijderen en opnieuw opbouwen.

Je kunt die bewering testen. Wanneer geld alleen kan bewegen via een append-only journaal, is een fout saldo geen mysterie meer maar een query die we kunnen uitvoeren.

Saldi zijn een projectie, geen getal dat je ophoogt

Het grootboek van Dimesum bezit drie tabellen: journals, postings en een balances-projectie met als sleutel groep, lid en valuta. De posten dragen de waarheid. Een post is positief wanneer een lid geld in de groep heeft gestopt en negatief wanneer het waarde heeft verbruikt, dus een saldo is simpelweg een SUM(amount_minor). De projectie bestaat voor snelheid, niet voor gezag: ze wordt binnen de eigen transactie van het journaal geschreven, en doordat de posten volledig zijn, blijft ze wegwerpbaar.

Twee lagen bevestigen dezelfde nul, en geen van beide is alleen voldoende

In Go weigert buildPostings een transactie te openen tenzij de set posten optelt tot nul. In Postgres hercontroleert een uitgestelde constraint-trigger bij de commit per journaal SUM(amount_minor) = 0, omdat posten één rij tegelijk worden ingevoegd en een controle per rij de eerste regel van elk journaal zou afwijzen. De assert vangt een bug in de calculator. De trigger vangt een schrijver die de assert nooit heeft aangeroepen.

Een derde laag is een recht. UPDATE, DELETE en TRUNCATE zijn op beide tabellen ingetrokken voor de rol ledger_app, zodat foutieve code de geschiedenis niet kan herschrijven, zelfs niet als ze het probeert.

Wat een append-only grootboek onmogelijk maakt, tegenover een opgehoogde saldotabel
Soort bugOpgehoogde saldotabelAppend-only grootboek
Debiteur belast, betaler nooit gecrediteerdVoor altijd stilzwijgend foutNulsomcontrole faalt, schrijfactie geweigerd
Een aandeel verandert, het totaal nietSaldi wijken stil afHet journaal kan niet ongebalanceerd committen
"Waarom is mijn saldo €412?"Niet te beantwoordenElke cent is herleidbaar tot een journaal
Een hotfix in productieNiet-gevolgde mutatieDe enige weg is een nieuw, geaudit journaal

Een bewerking boekt een terugboeking, nooit een UPDATE

Het bewerken van een uitgave schrijft niets over de oude versie heen. Het grootboek verwerkt één expense.amended-gebeurtenis en boekt twee journalen in één transactie: een EXPENSE_REVERSAL waarvan de posten de exacte negatie regel voor regel van de vervangen versie zijn, daarna een nieuw EXPENSE voor de nieuwe versie. Een verwijdering stopt na de terugboeking.

Eén transactie telt net zo zwaar als de twee journalen. Als de terugboeking alleen zou committen, zou een groep kort niets verschuldigd zijn voor een uitgave die nog wel openstaat.

Een uitgavebewerking boekt een EXPENSE_REVERSAL die versie één negeert, plus een nieuw EXPENSE voor versie twee, in één transactie; de balances-projectie is een som over alle posten. één transactie EXPENSE expense:7c1:v1 4 posten som = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal elke regel genegeerd som = 0 EXPENSE expense:7c1:v2 5 posten som = 0 balances-projectie = SUM(postings) per lid, per valuta wegwerpbaar: evenly rebuild-balances wist ze en speelt elke post opnieuw af
Een bewerking voegt toe: een terugboeking benoemt de versie die ze negeert, en de vervanging wordt ernaast geboekt.

Beide idempotentiesleutels worden afgeleid van de versie in plaats van aangemaakt: expense:<id>:v<n> voor de versie, en de sleutel van de vorige versie plus :reversal voor de negatie. journals.idempotency_key is UNIQUE, dus een opnieuw bezorgde gebeurtenis vindt haar journalen al aanwezig en doet niets. Afleiding is wat at-least-once-bezorging veilig maakt: een nieuwe poging berekent dezelfde sleutel die de eerste poging gebruikte.

Een watermerk op commitvolgorde houdt een wijziging achter haar uitgave

Elke gebeurtenis die Dimesum publiceert, wordt naar een outbox-tabel geschreven in dezelfde transactie als de bedrijfsschrijfactie. Als de uitgave commit, bestaat de aankondiging. Als ze terugrolt, doet de aankondiging dat ook. Het grootboek hoort nooit over een uitgave die niet bestaat.

Verzendvolgorde is de lastigere helft. Ids zijn UUIDv7 en sorteren op tijd, maar ze coderen wanneer het id is aangemaakt, niet wanneer de transactie ervan committe. Een relay die op id-volgorde leest, kan een transactie overslaan die een eerder id nam en later committe, zodat een wijziging de uitgave inhaalt die ze wijzigt.

De bedrijfsschrijfactie en haar outbox-rij committen in één transactie; de relay verzendt vervolgens alleen outbox-rijen waarvan het ingevoegde transactie-id onder het pg_snapshot_xmin-watermerk ligt, op volgorde van transactie-id. één transactie INSERT uitgave-rij INSERT outbox-rij topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 schrijver onderweg event bus grootboek consument pg_snapshot_xmin(pg_current_snapshot()). Rijen erboven zijn geschreven door transacties die hebben gecommit terwijl geen oudere meer loopt. De rij eronder wacht op de volgende ronde. Sorteren op id zou 019a-1a8 als eerste verzenden, zodat een wijziging zou kunnen aankomen vóór de uitgave die ze wijzigt. Sorteren op inserted_xid kan dat niet: een latere gebeurtenis heeft een later xid.
De outbox-rij commit samen met de bedrijfsschrijfactie, en de relay verzendt onder het watermerk op volgorde van transactie-id.

De oplossing is één kolom en één predicaat. Elke outbox-rij draagt inserted_xid xid8 DEFAULT pg_current_xact_id(), en de relay leest alleen rijen WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), gesorteerd op dat xid (zie de PostgreSQL transactie-id-functies). Een causaal latere gebeurtenis draagt altijd een later xid, omdat ze de eerdere rij moest lezen om te bestaan.

De wijzigingsconsument neemt die volgorde niet op vertrouwen aan. Een wijziging waarvan de voorganger geen journaal heeft, wordt opnieuw bezorgd zolang ze jong is, en na een respijt van twee minuten geparkeerd voor een mens.

Geld is int64 in minor units, en de exponent is niet altijd twee

Elk bedrag is een int64-telling van minor units plus een ISO 4217-code, dus 1.234,56 roepie is {Minor: 123456, Currency: "INR"}. Gehele getallen zijn exact door constructie, niet door discipline: geen enkele representeerbare waarde is een halve cent, dus geen enkele bewerking kan er stilletjes een produceren. De Python-sidecar leest haar eigen AST en laat een test falen als het woord float in haar bedragmodule voorkomt.

Aannemen dat de minor unit een honderdste is, is de valkuil eronder. JPY heeft er helemaal geen en KWD heeft drie decimalen, dus een koers die tussen major units wordt genoteerd en op minor units wordt toegepast, zit er met een macht van tien naast. Neem 2.000 yen tegen 0,58: het naïeve product is 1160, wat gelezen wordt als 11,60 roepie, terwijl het antwoord 1.160 is.

WRITE_OFF is een vijfde journaaltype omdat haar posten overeenkomen met die van een verrekening

Een afboeking boekt dezelfde twee regels als een verrekening: de debiteur stijgt, de crediteur daalt, met hetzelfde bedrag. Het onderbrengen ervan in SETTLEMENT werd afgewezen om precies de reden dat de vormen overeenkomen. "Asha heeft je €500 betaald" en "je hebt Asha €500 kwijtgescholden" zijn verschillende feiten, en een feed die ze samenvoegt, zou zeggen dat iemand betaalde terwijl niemand dat deed.

Daarom werd WRITE_OFF op 2026-08-21 toegevoegd aan de CHECK op journaaltype, met eigen regels. Het benoemen ervan was het halve punt, want een afboeking die SETTLE_PAY hergebruikt, zou elke query van "hoeveel is er werkelijk betaald" stilzwijgend fout maken.

De vijf journaaltypen in het Dimesum-grootboek en de regels die elk boekt
JournaaltypeGeboekt wanneerRegels
EXPENSEAangemaakt, of een nieuwe versie vervangt er eenPAID, SHARE
EXPENSE_REVERSALBewerkt of verwijderdDe regels van het vorige journaal, genegeerd
SETTLEMENTEen terugbetaling wordt vastgelegdSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALDe tegenpartij betwist hetBeide verrekenregels, genegeerd
WRITE_OFFEen crediteur ziet af van een vorderingWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

Twee subcommando's maken van de invarianten een cronjob

evenly verify-ledger bewijst de invarianten opnieuw over het hele schema en eindigt met een niet-nul-code bij elke treffer. Het rapport heeft vier velden, en van elk wordt verwacht dat het leeg is: journalen die niet optellen tot nul, groepen die niet optellen tot nul, projectierijen die afwijken van een herberekende SUM(postings), en geparkeerde gebeurtenissen die op een mens wachten. Alle scans delen één repeatable-read-snapshot, zodat een journaal dat tijdens de verificatie binnenkomt, geen afwijking kan fabriceren.

Elke scan groepeert op valuta én op id, en de fout die dat voorkomt is een vals-negatief. Een post van 500 roepie en een van min 500 yen tellen op tot nul wanneer een query de valutakolom negeert, dus een dubbel corrupt grootboek zou als schoon gelezen worden.

evenly rebuild-balances [group-id] is zowel de reparatie als de oefening. Ze wist de projectie en herberekent elke rij uit de posten, en stempelt op elke rij het laatste journaal dat dat lid verplaatste, net zoals de live-schrijfactie doet. Gelijkheid rij voor rij is de eigenschap: wanneer een herbouw en de live-projectie het oneens zijn, heeft het grootboek gelijk.

Voer ze in deze volgorde uit

Eerst verify, dan rebuild. Het rapport noemt voor elke afwijkende rij het opgeslagen saldo en het herberekende saldo, en een herbouw overschrijft de opgeslagen waarde, dus eerst herbouwen vernietigt het bewijs.

Maak het saldo afleidbaar en afwijking wordt een query

Een append-only grootboek verdient zijn tweede journaal alleen als je het kunt bewijzen, dus bouw de verificatie en de herbouw vóór de functie die ze nodig heeft. Een herbouw die niemand heeft uitgevoerd, is een hoop, geen nooduitgang. Kies deze week je meest riskante geldprojectie, schrijf de query die ze uit de bron herberekent, en laat jezelf piepen wanneer de twee het oneens zijn.

Veelgestelde vragen

Wat is een append-only grootboek in een app om uitgaven te delen?

Een append-only grootboek legt elke geldgebeurtenis vast als een journaal van posten die optellen tot nul, en werkt of verwijdert er nooit een. In Dimesum voegen een uitgave, een bewerking, een verrekening, een geschil en een afboeking elk een nieuw journaal toe. Saldi worden vervolgens afgeleid door posten op te tellen, dus elke cent is te herleiden tot de gebeurtenis die hem verplaatste.

Hoe verwerkt een append-only grootboek een bewerkte uitgave?

Een bewerking boekt twee journalen in één transactie: een EXPENSE_REVERSAL die het origineel regel voor regel negeert, gevolgd door een nieuw EXPENSE met de vervangende versie. Er wordt niets herschreven, en een verwijdering boekt alleen de terugboeking. Beide journalen krijgen idempotentiesleutels die zijn afgeleid van de uitgaveversie, zodat een opnieuw bezorgde gebeurtenis ze al aanwezig vindt en niets verandert.

Waarom geld opslaan als gehele getallen in plaats van floats?

Gehele minor units zijn exact door constructie, terwijl binaire floating point 0,1 niet exact kan weergeven en over de geschiedenis van een groep afdrijft. Dimesum slaat elk bedrag op als een int64-telling van minor units plus een ISO 4217-code. De exponent volgt uit de valuta: JPY heeft helemaal geen minor unit, dus aannemen dat het een honderdste is, is een fout van 100x.

Hoe weet je dat gedeelde saldi niet zijn afgeweken?

Voer evenly verify-ledger uit, die de invarianten over het hele schema opnieuw bewijst en met een niet-nul-code eindigt bij elke treffer. Het meldt journalen die niet optellen tot nul, groepen die niet optellen tot nul, projectierijen die niet overeenkomen met een herberekende SUM(postings), en geparkeerde gebeurtenissen. Zet het nachtelijks in de cron, en herstel een foute projectie met evenly rebuild-balances.

Waarom heeft een afboeking een eigen journaaltype nodig?

Een afboeking boekt regels die identiek zijn aan die van een verrekening, en juist daarom moest het journaaltype verschillen. Alleen dat woord scheidt 'Asha heeft je €500 betaald' van 'je hebt Asha €500 kwijtgescholden', en een feed die ze samenvoegt, zou zeggen dat iemand betaalde terwijl niemand dat deed. De regels heten WRITE_OFF_FORGIVEN en WRITE_OFF_GRANTED zodat betalingsquery's correct blijven.