tawasuyu/hifas
Motor en Rust para decidir, con demostración y no a ojo, cuándo un conjunto de réplicas local-first puede operar sin coordinación y cuándo no — y, cuando la respuesta es "sí, dentro de límites", proveer el mecanismo que lo garantiza.
T11c: corridas de testigo — CloneDetected + §G.4 rollback con dientes
1//! `durable` — que `spent` no pueda olvidar (T11b, F1 / §B).
2//!
3//! T10 asumía en silencio que `spent_i` es **monótono en la fuente**: un escritor
4//! nunca retrocede lo que ya cometió. Aquí ese supuesto se hace real con un
5//! **write-ahead log**: un gasto se reconoce al usuario (`ack`) **solo después**
6//! de persistirse. Un crash puede perder gastos *no reconocidos* (nunca
7//! prometidos), jamás uno reconocido.
8//!
9//! Modelo: el WAL es lo durable (sobrevive al crash); el `live` en memoria es
10//! volátil. `recover` reconstruye `live` desde el WAL ⟹ nunca por debajo del
11//! último ack. Cero coordinación: cada nodo fsyncea lo suyo. Combinado con el
12//! `merge` por máximo de T10, garantiza la monotonía que el certificado exige.
13
14/// Prueba de que un gasto quedó comprometido de forma durable antes del ack.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct AckToken {
17 /// Total comprometido (durable) en el momento del ack.
18 pub committed_total: i64,
19}
20
21/// El log durable de gastos de una celda-dispositivo. Solo crece.
22#[derive(Debug, Clone, Default)]
23pub struct WriteAheadLog {
24 committed: i64,
25}
26
27impl WriteAheadLog {
28 /// WAL vacío.
29 pub fn new() -> Self {
30 Self::default()
31 }
32
33 /// Compromete un gasto (`amount ≥ 0`): **persiste primero** ("fsync") y solo
34 /// entonces devuelve el `AckToken`. Monótono: `committed` solo crece.
35 pub fn commit_spend(&mut self, amount: i64) -> AckToken {
36 debug_assert!(amount >= 0, "los gastos son no negativos");
37 self.committed += amount;
38 AckToken {
39 committed_total: self.committed,
40 }
41 }
42
43 /// Total comprometido y durable. Nunca retrocede.
44 pub fn committed(&self) -> i64 {
45 self.committed
46 }
47}
48
49/// El gasto de una celda-dispositivo, respaldado por un WAL durable.
50#[derive(Debug, Clone, Default)]
51pub struct DurableSpent {
52 wal: WriteAheadLog,
53 /// Valor en memoria (volátil): se pierde en un crash, se rehace desde el WAL.
54 live: i64,
55}
56
57impl DurableSpent {
58 /// Celda de gasto durable en cero.
59 pub fn new() -> Self {
60 Self::default()
61 }
62
63 /// Gasta `amount`: compromete al WAL (durable) y **luego** actualiza el valor
64 /// vivo y ackea. No hay ack sin commit durable.
65 pub fn spend(&mut self, amount: i64) -> AckToken {
66 let ack = self.wal.commit_spend(amount);
67 self.live = self.wal.committed();
68 ack
69 }
70
71 /// El gasto reconocido de la celda.
72 pub fn value(&self) -> i64 {
73 self.live
74 }
75
76 /// Total durable (lo que el WAL garantiza).
77 pub fn committed(&self) -> i64 {
78 self.wal.committed()
79 }
80
81 /// **Recuperación tras crash:** el estado en memoria se pierde; `live` se
82 /// reconstruye desde el WAL. Resultado: `value() == committed() ≥ último ack`.
83 /// Nunca re-gasta lo ya comprometido.
84 pub fn recover(&mut self) {
85 self.live = self.wal.committed();
86 }
87
88 /// **Defensa contra el adversario (§G.1):** un intento de restaurar un
89 /// snapshot *por debajo* del WAL (rollback → doble gasto) se **rechaza**.
90 /// Solo se acepta un valor `≥ committed` (que no retrocede). Devuelve si se
91 /// aceptó.
92 pub fn try_restore_snapshot(&mut self, snapshot: i64) -> bool {
93 if snapshot >= self.wal.committed() {
94 self.live = snapshot;
95 true
96 } else {
97 false // rollback por debajo del compromiso durable: RECHAZADO
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use proptest::prelude::*;
106
107 /// §G.1 — el WAL no olvida: se gasta y ackea, "crashea" (se pierde el estado
108 /// en memoria), recupera desde el WAL, y el `spent` recuperado es ≥ el último
109 /// ack. Nunca re-gasta lo ya comprometido.
110 #[test]
111 fn el_wal_sobrevive_al_crash() {
112 let mut d = DurableSpent::new();
113 d.spend(40);
114 let ack = d.spend(30);
115 assert_eq!(ack.committed_total, 70);
116
117 // "Crash": el valor en memoria se corrompe/pierde a un estado viejo...
118 d.live = 40; // (simula memoria stale que olvidó los últimos 30)
119 // ...pero la recuperación lo restaura desde el WAL durable.
120 d.recover();
121 assert_eq!(d.value(), 70, "recuperado ≥ último ack; no olvida");
122 }
123
124 /// §G.1 (adversario) — restaurar un snapshot por debajo del WAL se rechaza;
125 /// por encima (que no retrocede) se acepta.
126 #[test]
127 fn adversario_rollback_es_rechazado() {
128 let mut d = DurableSpent::new();
129 d.spend(70);
130 assert!(!d.try_restore_snapshot(50), "50 < 70 comprometido: rollback rechazado");
131 assert_eq!(d.value(), 70, "el rechazo no tocó el valor");
132 assert!(d.try_restore_snapshot(80), "80 ≥ 70: no retrocede, aceptado");
133 }
134
135 /// §G.4 — valor REAL del recurso tras un crash + rollback, con y sin la capa
136 /// de durabilidad. `real` es un tally independiente de lo que de verdad se
137 /// desembolsó; el certificado exige `real ≥ piso`.
138 ///
139 /// Escenario: celda con presupuesto 50 (cuenta 50, piso 0). Se gasta 50
140 /// (agota la celda), un crash revierte el estado en memoria a 0 (rollback
141 /// CRUDO, bypass del WAL), y se re-gasta.
142 fn valor_real_tras_rollback(con_durabilidad: bool) -> i64 {
143 let (initial, budget) = (50i64, 50i64);
144 let mut real = 0;
145 let mut cell = DurableSpent::new();
146
147 if budget - cell.value() >= 50 {
148 cell.spend(50); // ack durable del primer gasto
149 real += 50;
150 }
151 // Crash + rollback crudo: la memoria vuelve a un estado viejo (spent = 0).
152 cell.live = 0;
153 if con_durabilidad {
154 cell.recover(); // la durabilidad restaura spent = 50 desde el WAL
155 }
156 // Re-gasto, guardado por el `spent` visible en este momento.
157 if budget - cell.value() >= 50 {
158 cell.spend(50);
159 real += 50;
160 }
161 initial - real
162 }
163
164 /// §G.4 (verde) — con durabilidad, el rollback se atrapa (recover restaura el
165 /// WAL), el re-gasto se bloquea, y el certificado aguanta: valor real ≥ 0.
166 #[test]
167 fn g4_con_durabilidad_el_certificado_queda_verde() {
168 let v = valor_real_tras_rollback(true);
169 println!("§G.4 CON durabilidad: valor real = {v} (>= 0 ⟹ VERDE)");
170 assert!(v >= 0, "con durabilidad no hay doble gasto");
171 }
172
173 /// §G.4 (el diente) — SIN durabilidad, el rollback no atrapado provoca doble
174 /// gasto: valor real = -50 < 0. El invariante se rompe de verdad.
175 #[test]
176 fn g4_sin_durabilidad_el_rollback_sobregira() {
177 let v = valor_real_tras_rollback(false);
178 println!("§G.4 SIN durabilidad: valor real = {v} (< 0 ⟹ certificado ROJO)");
179 assert!(v < 0, "el rollback no atrapado DEBE sobregirar");
180 }
181
182 /// §G.4 (literal) — la aserción del certificado dinámico, ejecutada sobre el
183 /// rollback no atrapado, **falla ruidosamente**. Esto prueba que la capa de
184 /// durabilidad (§G.1) tiene dientes: sin ella, el certificado se pone rojo.
185 #[test]
186 #[should_panic(expected = "certificado en ROJO")]
187 fn g4_el_certificado_se_pone_rojo_bajo_rollback() {
188 let v = valor_real_tras_rollback(false);
189 assert!(v >= 0, "certificado en ROJO: valor real {v} < piso 0 por doble gasto");
190 }
191
192 proptest! {
193 /// §G.1 (con dientes) — sobre cualquier secuencia de gastos y crashes, el
194 /// valor recuperado nunca cae por debajo del último ack comprometido.
195 #[test]
196 fn spent_nunca_cae_por_debajo_del_ack(
197 ops in prop::collection::vec((any::<bool>(), 0i64..100), 0..40)
198 ) {
199 let mut d = DurableSpent::new();
200 for (crash, amount) in ops {
201 if crash {
202 // Se pierde memoria (a un estado arbitrario viejo) y se recupera.
203 d.live = 0;
204 d.recover();
205 } else {
206 d.spend(amount);
207 }
208 prop_assert!(d.value() >= d.committed(), "por debajo del WAL");
209 prop_assert_eq!(d.value(), d.committed(), "value se ancla al WAL");
210 }
211 }
212
213 /// El adversario nunca logra revertir por debajo del compromiso durable.
214 #[test]
215 fn rollback_por_debajo_del_wal_siempre_rechazado(
216 gastos in prop::collection::vec(0i64..50, 1..8),
217 snapshot in -20i64..300,
218 ) {
219 let mut d = DurableSpent::new();
220 for g in gastos {
221 d.spend(g);
222 }
223 let committed = d.committed();
224 let aceptado = d.try_restore_snapshot(snapshot);
225 if snapshot < committed {
226 prop_assert!(!aceptado);
227 prop_assert_eq!(d.value(), committed, "rechazo no retrocede");
228 } else {
229 prop_assert!(aceptado);
230 prop_assert!(d.value() >= committed);
231 }
232 }
233 }
234}