dimesum

Home / Blog / Ingegneria

Ingegneria

Il registro append-only per spese condivise esatte

· 9 min di lettura ·

Un saldo dovrebbe essere una proiezione che puoi buttare via e ricostruire, e questa è la meccanica che lo rende sicuro.

Un saldo che si incrementa è un saldo che va alla deriva. Dimesum non ne incrementa mai uno. Ogni spesa registra una scrittura in partita doppia le cui righe hanno somma esattamente zero, e il numero sulla schermata del tuo gruppo è una proiezione su quelle righe che possiamo cancellare e ricostruire con un solo comando.

Puoi verificare questa affermazione. Quando l'unico modo in cui il denaro si muove è una scrittura append-only, un saldo sbagliato smette di essere un mistero e diventa una query che possiamo eseguire.

I saldi sono una proiezione, non un numero che si incrementa

Il registro di Dimesum possiede tre tabelle: journals, postings e una proiezione balances indicizzata per gruppo, membro e valuta. Le righe portano la verità. Una riga è positiva quando un membro ha messo denaro nel gruppo e negativa quando ha consumato valore, quindi un saldo è un semplice SUM(amount_minor). La proiezione esiste per velocità, non per autorità: viene scritta all'interno della transazione stessa della scrittura, e la completezza delle righe la mantiene eliminabile.

Due livelli affermano lo stesso zero, e nessuno dei due basta da solo

In Go, buildPostings si rifiuta di aprire una transazione se l'insieme delle righe non ha somma zero. In Postgres, un trigger di vincolo differito ricontrolla SUM(amount_minor) = 0 per ogni scrittura al momento del commit, perché le righe vengono inserite una alla volta e un controllo riga per riga rifiuterebbe la prima riga di ogni scrittura. L'assert intercetta un bug nel calcolatore. Il trigger intercetta uno scrittore che non l'ha mai chiamato.

Un terzo livello è un permesso. UPDATE, DELETE e TRUNCATE sono revocati dal ruolo ledger_app su entrambe le tabelle, così il codice difettoso non può riscrivere la storia nemmeno quando ci prova.

Ciò che un registro append-only rende impossibile, rispetto a una tabella di saldi incrementata
Classe di bugTabella di saldi incrementataRegistro append-only
Debitore addebitato, pagante mai accreditatoSbagliato in silenzio per sempreIl controllo a somma zero fallisce, scrittura rifiutata
Una quota cambia, il totale noI saldi vanno alla deriva silenziosamenteLa scrittura non può essere confermata se non è bilanciata
"Perché il mio saldo è €412?"Senza rispostaOgni centesimo risale a una scrittura
Una correzione rapida in produzioneMutazione non tracciataL'unica via è una nuova scrittura verificabile

Una modifica registra uno storno, mai un UPDATE

Modificare una spesa non sovrascrive nulla della vecchia versione. Il registro consuma un evento expense.amended e registra due scritture in un'unica transazione: uno EXPENSE_REVERSAL le cui righe sono l'esatta negazione riga per riga della versione che viene sostituita, poi una nuova EXPENSE per quella nuova. Un'eliminazione si ferma dopo lo storno.

L'unica transazione conta quanto le due scritture. Se lo storno fosse confermato da solo, un gruppo per un istante non dovrebbe nulla per una spesa che ancora deve.

La modifica di una spesa registra uno EXPENSE_REVERSAL che nega la versione uno, più una nuova EXPENSE per la versione due, in un'unica transazione; la proiezione dei saldi è una somma su tutte le righe. una transazione EXPENSE expense:7c1:v1 4 righe somma = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal ogni riga negata somma = 0 EXPENSE expense:7c1:v2 5 righe somma = 0 proiezione dei saldi = SUM(postings) per membro, per valuta eliminabile: evenly rebuild-balances la azzera e riproduce ogni riga
Una modifica aggiunge: uno storno nomina la versione che nega, e la sostituzione viene registrata accanto ad esso.

Entrambe le chiavi di idempotenza sono derivate dalla versione anziché generate: expense:<id>:v<n> per la versione, e la chiave della versione precedente più :reversal per la negazione. journals.idempotency_key è UNIQUE, quindi un evento riconsegnato trova le sue scritture già presenti e non fa nulla. La derivazione è ciò che rende sicura la consegna at-least-once: un nuovo tentativo calcola la chiave usata dal primo tentativo.

Un watermark in ordine di commit mantiene una modifica dietro la sua spesa

Ogni evento che Dimesum pubblica viene scritto in una tabella outbox nella stessa transazione della scrittura di business. Se la spesa viene confermata, l'annuncio esiste. Se viene annullata, lo è anche l'annuncio. Il registro non sente mai parlare di una spesa che non esiste.

L'ordine di invio è la metà più difficile. Gli id sono UUIDv7 e si ordinano per tempo, ma codificano quando l'id è stato generato, non quando la sua transazione è stata confermata. Un relay che legge in ordine di id può saltare oltre una transazione che ha preso un id precedente e si è confermata dopo, così una modifica supera la spesa che modifica.

La scrittura di business e la sua riga di outbox vengono confermate in un'unica transazione; il relay poi invia solo le righe di outbox il cui id di transazione inserito è sotto il watermark pg_snapshot_xmin, in ordine di id di transazione. una transazione INSERT riga spesa INSERT riga outbox topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 scrittore in corso bus di eventi registro consumatore pg_snapshot_xmin(pg_current_snapshot()). Le righe sopra di esso sono state scritte da transazioni che si sono confermate senza che ne resti in esecuzione una più vecchia. La riga sotto attende il passaggio successivo. Ordinare per id invierebbe prima 019a-1a8, così una modifica potrebbe arrivare prima della spesa che modifica. Ordinare per inserted_xid non può: un evento successivo ha un xid successivo.
La riga di outbox viene confermata con la scrittura di business, e il relay invia sotto il watermark in ordine di id di transazione.

La correzione è una colonna e un predicato. Ogni riga di outbox porta inserted_xid xid8 DEFAULT pg_current_xact_id(), e il relay legge solo le righe WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), ordinate per quell'xid (vedi le funzioni per gli id di transazione di PostgreSQL). Un evento causalmente successivo porta sempre un xid successivo, perché per esistere ha dovuto leggere la riga precedente.

Il consumatore delle modifiche non dà per scontato quell'ordine. Una modifica il cui predecessore non ha una scrittura viene riconsegnata finché è recente, e messa da parte per un umano una volta superati due minuti di tolleranza.

Il denaro è in unità minori int64, e l'esponente non è sempre due

Ogni importo è un conteggio int64 di unità minori più un codice ISO 4217, quindi 1.234,56 rupie sono {Minor: 123456, Currency: "INR"}. Gli interi sono esatti per costruzione, non per disciplina: nessun valore rappresentabile è mezzo centesimo, quindi nessuna operazione può produrne uno di nascosto. Il sidecar Python legge il proprio AST e fa fallire un test se la parola float compare nel suo modulo degli importi.

Assumere che l'unità minore sia un centesimo è la trappola sottostante. JPY non ne ha affatto e KWD ha tre decimali, quindi un tasso quotato tra unità maggiori e applicato a unità minori è sbagliato di una potenza di dieci. Prendi 2.000 yen a 0,58: il prodotto ingenuo è 1160, che si legge come 11,60 rupie, quando la risposta è 1.160.

WRITE_OFF è un quinto tipo di scrittura perché le sue righe coincidono con quelle di un regolamento

Uno storno registra le stesse due righe di un regolamento: il debitore sale, il creditore scende, dello stesso importo. Accorparlo in SETTLEMENT è stato respinto esattamente per il motivo per cui le forme coincidono. "Asha ti ha pagato 500 euro" e "hai condonato ad Asha 500 euro" sono fatti diversi, e un feed che li confondesse direbbe che qualcuno ha pagato quando nessuno l'ha fatto.

Così WRITE_OFF si è unito al CHECK dei tipi di scrittura il 2026-08-21, con righe proprie. Nominarle era metà del punto, perché uno storno che riutilizzasse SETTLE_PAY renderebbe ogni query "quanto è stato effettivamente pagato" sbagliata in silenzio.

I cinque tipi di scrittura nel registro Dimesum e le righe che ciascuno registra
Tipo di scritturaRegistrato quandoRighe
EXPENSECreata, o una nuova versione ne sostituisce unaPAID, SHARE
EXPENSE_REVERSALModificata o eliminataLe righe della scrittura precedente, negate
SETTLEMENTViene affermato un rimborsoSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALLa controparte lo contestaEntrambe le righe di regolamento, negate
WRITE_OFFUn creditore rinuncia a un creditoWRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED

Due sottocomandi trasformano gli invarianti in un cron job

evenly verify-ledger ridimostra gli invarianti sull'intero schema ed esce con codice diverso da zero a ogni riscontro. Il suo report ha quattro campi, e ci si aspetta che ognuno sia vuoto: scritture che non hanno somma zero, gruppi che non hanno somma zero, righe di proiezione che divergono da un SUM(postings) ricalcolato, ed eventi messi da parte in attesa di un umano. Tutte le scansioni condividono un unico snapshot repeatable-read, così una scrittura che arriva a metà verifica non può fabbricare una discrepanza.

Ogni scansione raggruppa per valuta oltre che per id, e il fallimento che questo evita è un falso negativo. Una riga di 500 rupie e una di meno 500 yen hanno somma zero quando una query ignora la colonna della valuta, quindi un registro doppiamente corrotto risulterebbe pulito.

evenly rebuild-balances [group-id] è la riparazione e l'esercitazione. Azzera la proiezione e ricalcola ogni riga dalle righe di scrittura, marcando ciascuna con l'ultima scrittura che ha mosso quel membro, come fa la scrittura in tempo reale. L'uguaglianza riga per riga è la proprietà: quando una ricostruzione e la proiezione in tempo reale non concordano, il registro ha ragione.

Eseguili in quest'ordine

Verifica, poi ricostruisci. Il report nomina il saldo memorizzato e quello ricalcolato per ogni riga alla deriva, e una ricostruzione sovrascrive il valore memorizzato, quindi ricostruire prima distrugge le prove.

Rendi il saldo derivabile e la deriva diventa una query

Un registro append-only guadagna la sua seconda scrittura solo se puoi dimostrarlo, quindi costruisci il verificatore e la ricostruzione prima della funzionalità che ne ha bisogno. Una ricostruzione che nessuno ha mai eseguito è una speranza, non una via di fuga. Scegli la tua proiezione di denaro più rischiosa questa settimana, scrivi la query che la ricalcola dalla fonte, e fatti avvisare quando le due non concordano.

Domande frequenti

Cos'è un registro append-only in un'app per dividere le spese?

Un registro append-only registra ogni evento di denaro come una scrittura di righe con somma zero, e non ne aggiorna né elimina mai una. In Dimesum, una spesa, una modifica, un regolamento, una contestazione e uno storno aggiungono ciascuno una nuova scrittura. I saldi vengono poi derivati sommando le righe, così ogni centesimo risale all'evento che l'ha mosso.

Come gestisce un registro append-only una spesa modificata?

Una modifica registra due scritture in un'unica transazione: uno EXPENSE_REVERSAL che nega l'originale riga per riga, poi una nuova EXPENSE che porta la versione sostitutiva. Nulla viene riscritto, e un'eliminazione registra solo lo storno. Entrambe le scritture prendono chiavi di idempotenza derivate dalla versione della spesa, così un evento riconsegnato le trova già presenti e non cambia nulla.

Perché memorizzare il denaro come interi invece che come float?

Le unità minori intere sono esatte per costruzione, mentre la virgola mobile binaria non può rappresentare esattamente 0,1 e va alla deriva lungo la storia di un gruppo. Dimesum memorizza ogni importo come un conteggio int64 di unità minori più un codice ISO 4217. L'esponente viene dalla valuta: JPY non ha alcuna unità minore, quindi assumere un centesimo è un errore di 100x.

Come faccio a sapere che i saldi delle spese condivise non sono andati alla deriva?

Esegui evenly verify-ledger, che ridimostra gli invarianti sull'intero schema ed esce con codice diverso da zero a ogni riscontro. Segnala scritture che non hanno somma zero, gruppi che non hanno somma zero, righe di proiezione che non concordano con un SUM(postings) ricalcolato, ed eventi messi da parte. Programmalo ogni notte con cron, e ripara una proiezione errata con evenly rebuild-balances.

Perché uno storno ha bisogno di un proprio tipo di scrittura?

Uno storno registra righe identiche a quelle di un regolamento, ed è esattamente per questo che il tipo di scrittura doveva differire. Solo quella parola separa 'Asha ti ha pagato 500 euro' da 'hai condonato ad Asha 500 euro', e un feed che li confondesse direbbe che qualcuno ha pagato quando nessuno l'ha fatto. Le sue righe si chiamano WRITE_OFF_FORGIVEN e WRITE_OFF_GRANTED così le query sui pagamenti restano corrette.