Sistemas backend
Ledgerline
Una API de eventos que conserva el orden, evita duplicados al reintentar y detecta cambios en el historial guardado.
- Rol
- Período
- Stack
- Enlaces
En números
99
pruebas en 15 archivos, ejecutadas en Workers con Durable Objects reales y una base D1 local
12
casos de falla documentados, cada uno con su comportamiento esperado y una prueba
8
errores encontrados en revisión, cada uno corregido y cubierto por una prueba de regresión
1
dependencia de producción (Hono)
- Cliente
- Worker
- Durable Object
- D1
- Cliente a Worker POST /v1
/streams Con el encabezado Idempotency-Key./:id /events - Worker Clave, límite de solicitudes y propietario Busca la clave de API por su hash SHA-256, consume un token del límite de solicitudes y verifica que el flujo pertenezca a esa clave. Si no quedan tokens, devuelve 429 con Retry-After.
- Worker a Durable Object append(payload, key) Se dirige con idFromName(streamId).
- Durable Object block
Concurrency Reutilizar una clave con el mismo cuerpo devuelve el {seq, hash} original. Un cuerpo distinto genera un conflicto. Una clave nueva recibe seq + 1 y el siguiente hash.While - Durable Object Una sola escritura atómica Guarda juntos el evento, el registro de la clave, el resumen del minuto, la cabecera del flujo y la alarma del outbox. Se confirma todo o nada.
- Durable Object a Worker {seq, hash}
- Worker a D1 INSERT OR IGNORE Copia el evento a D1 de inmediato para que las lecturas suelan ver la nueva escritura. Si falla, el cliente recibe de todos modos el resultado.
- Worker a Cliente 201 {seq, hash} Un reintento devuelve 200 con Idempotent-Replay: true. Un cuerpo distinto devuelve 422.
- Durable Object a D1 alarm(): barrido del outbox Tras unos 5 s, inserta las filas que faltan en D1. Reintenta cada 30 s mientras fallen las escrituras.
Cadena de hashes
- génesis SHA-256("ledgerline:v2:" + stream
Id) - h1 evento 1
- h2 evento 2
- h3 evento 3
hn = SHA-256(hn-1 | JSON canónico RFC 8785 | n)
/verify reconstruye la cadena desde el génesis y revisa cada seq, prevHash y hash. Al comparar el resultado con la cabecera guardada, también detecta eventos borrados al final.
La idea
Ledgerline es una API HTTP para libros contables, registros de auditoría y otros historiales que deben conservarse intactos. Solo permite agregar eventos. Los reintentos no duplican escrituras, la secuencia no tiene saltos y una cadena de hashes permite detectar ediciones, borrados o un historial recortado.
Lo que construí
Cada flujo de eventos tiene su propio Durable Object en Cloudflare Workers. Dentro de blockConcurrencyWhile, revisa la clave de idempotencia, asigna un número de secuencia y enlaza el evento a la cadena de hashes. Una escritura atómica guarda el evento, el registro de la clave, el resumen del minuto, la cabecera del flujo y una alarma de outbox. La operación se guarda completa o no escribe nada.
D1 mantiene un modelo de lectura para el historial paginado. El Worker copia cada evento de inmediato. Si una inserción falla, la copia se atrasa y la alarma del outbox sigue reintentando hasta que el evento esté en D1.
El hash de cada evento incluye su JSON canónico según RFC 8785, el hash anterior y su número de secuencia. Cada flujo conserva una versión de esa regla. Las claves de API se guardan como hashes SHA-256. Pedir el flujo de otra clave devuelve 404, y un límite de solicitudes por clave devuelve 429 con Retry-After.
Las 99 pruebas corren en Workers con Durable Objects reales y una base D1 local, incluidas fallas de D1 provocadas a propósito. CI las ejecuta en cada push a main y en cada pull request.
Retos técnicos
-
Reintentos sin escrituras duplicadas
Las claves de idempotencia duran tanto como su flujo. Un reintento con el mismo cuerpo, comparado en forma canónica, devuelve el {seq, hash} original con Idempotent-Replay: true, sin importar cuánto tiempo haya pasado. Reutilizar la clave con otro cuerpo devuelve 422 y no escribe nada. Una prueba envía 5 solicitudes simultáneas con la misma clave y verifica que se cree un solo evento.
-
Conservar el orden entre llamadas await
Un await dentro de un Durable Object, como una llamada a SHA-256, puede dejar que otra escritura empiece antes de que termine la primera. Ejecuto cada escritura dentro de blockConcurrencyWhile para conservar el orden. La prueba hace que el cálculo ceda el turno mientras se intercalan 20 escrituras y verifica que reciban los números 1 a 20. Sin esa protección, falla.
-
Actualizar la regla del hash
Los ejemplos de prueba del propio RFC 8785 revelaron un error: el canonicalizador original ordenaba mal las claves numéricas como "9" y "10". Reemplazarlo habría hecho fallar /verify en flujos intactos con esas claves. Publiqué la versión 2 de la cadena. Cada flujo conserva su versión original, incluida en el hash génesis, para que los flujos de la versión 1 puedan seguir agregando eventos y verificándose.
Antes y después
| Antes | Después | Fuente | |
|---|---|---|---|
| Reutilizar un Idempotency-Key con otro cuerpo | Devolvía el evento original como un reintento exitoso. | 422 idempotency_key_reused, y no se escribe nada. | Commit ff5270e, 9 de octubre de 2026 |
| Entrega al modelo de lectura en D1 | Una sola inserción después de guardar; si fallaba, podía quedar un hueco permanente. | Una alarma de outbox, programada en la misma escritura atómica, inserta las filas que faltan en D1 y reintenta cada 30 s hasta completar la entrega. | Commits d2404dc y 4650d2a, 9 de octubre de 2026 |
| JSON canónico para el hash | Se reordenaban las claves antes de llamar a JSON.stringify, que altera el orden de las claves numéricas exigido por RFC 8785. | RFC 8785 en la versión 2 de la cadena; los flujos de la versión 1 siguen verificándose. | Commits e4db446 y a6923e9, 9 de octubre de 2026 |
| Pruebas en el entorno de Workers | 69 en 14 archivos. | 99 en 15 archivos. CI falla si el README o la página publicada citan otro número. | test/*.test.ts el 2 de agosto y el 9 de octubre de 2026 |
Lo próximo
Publicar puntos de control firmados de la cabecera de cada flujo fuera del servidor. Así, /verify podría empezar desde el último y detectar una reescritura completa y consistente del registro.
Pruébalo
Enfoques compartidos
Probar con entornos y datos reales