Empecemos por la parte que más fácil se pierde al leer el resto: no se tomó dinero. Ningún monedero se cargó dos veces, ninguna tarjeta se cobró, ninguna asignación de suscripción se gastó en trabajo que nunca ocurrió.
Lo que se rompió fue un número en una pantalla. En algunas cuentas, el desglose de consumo de tu propia página de cuenta marcaba de más — en la peor que medimos, 2,4 veces el libro mayor que debía resumir. El libro mayor estuvo bien todo el tiempo. También los topes mensuales, que se leen de un sitio completamente distinto. Fue un defecto de visualización, ya está corregido, y esto es lo que lo causó.
Una clave que buscaba algo que nadie escribió
El trabajo que ocurre a raíz de algo que hiciste — actualizar un resumen diario de consumo, acreditar una recomendación, repartir una notificación — no ocurre dentro de tu petición. La petición escribe un mensaje y un consumidor en segundo plano lo recoge un instante después, que es lo que evita que un efecto secundario lento haga lenta una página rápida.
El peligro de ese arreglo es hacer el mismo trabajo dos veces, porque un broker reentrega alegremente el mismo mensaje tras un tropiezo. La defensa es una bandeja de entrada: antes de procesar, un consumidor reclama el mensaje escribiendo una fila con la clave de ese mensaje, y un índice único hace imposible una segunda reclamación.
La nuestra reclamaba sobre un par — el inquilino al que pertenecía el mensaje, más la clave del mensaje — y buscaba ese par contra el identificador de inquilino que lleva el sobre del mensaje, que sí viene relleno.
Las filas de reclamación no llevaban identificador de inquilino en absoluto. Todas estaban vacías, y lo habían estado desde el principio. La propia documentación de la entidad afirmaba, con esas mismas palabras, que el framework lo estampaba al insertar. Nunca lo hizo. Medido contra producción el día que lo encontramos: las 3.344 filas de la bandeja de entrada vacías, mientras que 1.174 de 1.451 filas del lado productor llevaban un inquilino real — que es exactamente por lo que nadie sospechó del lado lector.
Así que la búsqueda pedía (inquilino, clave), la fila guardada era (nada, clave), y jamás podía coincidir.
Nada falló, y por eso estuvo semanas funcionando
Una búsqueda que nunca puede coincidir suena a algo que debería dar error al primer intento. Hizo lo contrario.
La reclamación decidió que el mensaje era nuevo. La inserción chocó entonces contra el índice único y fue rechazada como duplicado — correctamente, porque la fila ya estaba ahí. Una rama de respaldo capturó ese rechazo y lo leyó con benevolencia: otra entrega debe de haber reclamado este, aquí no hay nada que hacer. El sobre se quedó en estado «aún por procesar», y como aún estaba por procesar, la siguiente entrega lo recogió e hizo el trabajo otra vez.
Cada consumidor reprocesaba su propio acumulado, más o menos cada cuarenta y cinco segundos, indefinidamente. Veintiséis lo hacían. Una fila de producción terminó 333 horas después de llegar, y había estado terminando una y otra vez durante casi todo ese tiempo.
Qué rompió realmente
De esos veintiséis consumidores, cuatro acumulan en lugar de sobrescribir. Esos cuatro son las proyecciones de facturación que lee la página de consumo, y ahí es donde esto salió a la superficie.
Tenían una protección explícita contra exactamente esto, escrita tras un incidente de agosto en el que un solo evento se aplicó nueve veces a un indicador. La protección marca una fila como correcta antes de procesarla, para que una reentrega encuentre la marca y se detenga. La marca se escribía bien cada vez. Luego la siguiente reclamación la buscaba con la misma clave rota y no la encontraba. La garantía estuvo vacía desde el día en que se publicó.
Lo que esto no tocó merece enumerarse con precisión, porque «proyección de facturación» suena alarmante y los hechos no lo son:
- El libro mayor era correcto. Cada llamada de IA escribe exactamente un registro de facturación con su propia instantánea de precio, y nada en esta ruta los escribe.
- Ningún monedero se movió. Los saldos salen del libro mayor, nunca de un resumen.
- Ningún tope se movió. El guardián del presupuesto lee un contador aparte, que es la única fuente de verdad sobre lo que has gastado este mes. Nadie quedó cortado antes de tiempo por un número inflado, y nadie pasó sin deber pasar.
Lo único que lee el resumen afectado es el historial de consumo de tu propia página de cuenta: tu total y el desglose de en qué se fue. Ese es todo el radio de impacto, y lo que hizo fue marcar de más.
La mitad que nadie estaba mirando
La misma búsqueda rota estaba en un segundo sitio: la ruta que se ejecuta cuando un mensaje falla. Ahí nunca encontraba la fila que debía actualizar, y justo debajo había una rama con el comentario «inalcanzable en la práctica» — que devolvía «este fallo es terminal» en cada fallo.
Así que el contador de reintentos nunca avanzó y ningún mensaje envenenado llegó jamás a la cola de mensajes fallidos. Una capa entera de manejo de errores estaba apagada, detrás de un comentario que le decía a todo el que lo leyera que ahí no había nada que comprobar.
La corrección
Ambas búsquedas se hacen ahora por la clave del mensaje dentro del grupo de consumidores, que es lo que el índice único impone de todos modos. Las claves de mensaje derivan del identificador de aquello que las produjo, así que no pueden colisionar entre inquilinos por construcción.
La documentación que afirmaba lo del estampado se corrigió en lugar de borrarse. Esa frase era la trampa, y borrarla no habría dejado nada que dijera que alguien por fin lo comprobó.
Nueve pruebas fijan el comportamiento. Seis de ellas fallan contra el código viejo, cosa que confirmamos volviendo a poner el código viejo.
Limpiar después
Las filas infladas se reparan solas. Un trabajador reconstruye los resúmenes diarios a partir del libro mayor cada hora, sobre una ventana de dos días. Había estado funcionando todo el tiempo y perdiendo: una vez por hora contra un consumidor que reaplica cada cuarenta y cinco segundos no es una carrera justa.
Con el consumidor ya sin reejecutarse, gana. Los dos días que seguían dentro de esa ventana se reconstruyeron desde el libro mayor en cuanto la corrección estuvo en producción. Dos días anteriores ya habían caído fuera, y lo que queda en ellos es una cuenta nuestra más una diferencia insignificante en otro sitio — así que los dejamos en lugar de ensanchar una ventana en producción para perseguirlo, lo que pareció mejor que insinuar que el barrido fue total.
Lo que nos llevamos
Una clave de deduplicación tiene que reflejar lo que el índice impone de verdad, no lo que el código cree que se escribió. Las dos mitades de esta se alimentaban de campos distintos y ninguna se había medido nunca contra la otra. Ahora ambas lo están, y la prueba que lo demuestra se pone en rojo contra la versión que estaba mal.