De dónde viene realmente «database is locked»: Hermes corrigió la contención de bloqueos, los escritores duplicados y una falsa alarma

Programas un cron job la noche del domingo. El lunes por la mañana abres una terminal para leer los resultados y el primer comando se queda ahí, colgado. Veinte segundos después se rinde con database is locked. No tienes dos instancias de Hermes corriendo. Nunca tocaste la base de datos a mano. Entonces, ¿quién se llevó el bloqueo?
Un lote de correcciones fusionado el 11 de septiembre responde a eso, y la respuesta es contraintuitiva: el bloqueo era Hermes tomando autoridad de escritura sobre sí mismo cada vez que abría el almacén de sesiones — y varios procesos no debían tocar ese archivo en absoluto. Abajo: quién tomaba el bloqueo, quién escribía en silencio y qué se juzgó mal.
1. Abrir el almacén tomaba un bloqueo de escritura incluso cuando no hacía falta escribir
Contexto: Hermes guarda cada sesión en un archivo SQLite llamado state.db — session_search, hermes sessions, las exportaciones y los insights leen de ahí. La regla de concurrencia de SQLite es tajante: una sentencia que podría escribir toma el bloqueo de escritura, incluso cuando no coincide con ninguna fila.
Ahí estaba la trampa. Cada apertura de lectura-escritura de state.db ejecutaba además un conjunto de sentencias que no cambiaban nada en una base de datos ya establecida:
- insertar el sello de generación (
INSERT OR IGNORE); UPDATE messages SET active=1 WHERE active IS NULL;- hacer upsert del marcador de versión de almacenamiento de FTS;
DROP TRIGGER IF EXISTSmásCREATE TRIGGERpara cuatro triggers de visualización.
Así que una base de datos perfectamente sana, sin nada pendiente, igual obligaba a los procesos hermanos — un tick de cron, una llamada a hermes sessions, un chequeo de doctor — a esperar toda su transacción. Pasada la ventana de paciencia, la apertura fallaba directamente con database is locked.
La solución es leer primero y escribir solo cuando algo está realmente desactualizado: insertar el sello solo si falta, sanear active IS NULL solo cuando existe una fila NULL, saltarse el marcador cuando el valor guardado ya coincide, y recrear los triggers solo cuando la definición almacenada en sqlite_master.sql difiere de la deseada.
Los números del pull request lo dejan claro:
| Escenario | Antes | Después |
|---|---|---|
| Base de datos establecida, un proceso hermano retiene el bloqueo de escritura 4 s | la apertura tardaba 4,14 s | 0,01 s |
| El titular sobrevive a la ventana de paciencia | database is locked después de 20,5 s |
abre con normalidad |
El atasco nunca fue «otro está usando la base de datos». Era «pedimos permiso de escritura que no necesitábamos».
2. Quién actuaba en silencio como un segundo escritor
El mismo lote encontró cuatro roles que trataban el almacén principal de sesiones como papel de borrador:
- El almacén de coordinación de group chat entre profiles vivía en el
state.dbdel gateway raíz, mientras que cada gateway de profile arranca ese worker incondicionalmente — lo que convertía a cada proceso de profile en un escritor de larga vida sobre el almacén maestro de sesiones. Ahora usashared-state.dbbajo la raíz de instalación. - El web dashboard ahora reconcilia el esquema al arrancar mediante una apertura de solo lectura. Un almacén sano no ve ninguna apertura escribible; solo un esquema realmente roto toma la única pasada de escritura.
- El guard de ciclo de vida del cron escaneaba el texto de los comandos en busca de tokens con forma de ruta y los leía con un
os.opencrudo. Un comando que solo mencionara elstate.dbdel gateway podía cancelar los bloqueos POSIX del gateway. Esas lecturas ahora pasan por el registro rastreado de acceso a archivos offline. hermes doctor --fixahora comprueba si hay un escritor vivo antes de su checkpoint de WAL. Con un gateway en ejecución se salta el checkpoint con un hallazgo accionable en lugar de unirse al WAL como segunda conexión.
En conjunto, esto explica por qué database is locked volvía una y otra vez en máquinas reales: no un bug, sino una base de datos con varios escritores.
3. Un WAL sano fue declarado muerto
La tercera clase es más sutil. Hermes tiene un guard: si los archivos sidecar del WAL de state.db se desvinculan, cae a almacenamiento JSONL — más lento, pero seguro. La evidencia antigua era el sufijo " (deleted)" en las entradas de /proc/<pid>/fd.
En OpenZFS, un archivo -wal/-shm vivo y todavía enlazado se reporta exactamente así (dentry sin hash, nlink=1). Así que el guard se disparaba de forma permanente y el gateway corría en JSONL para siempre — degradado y completamente en silencio.
Ahora la comprobación compara el (st_dev, st_ino) del file descriptor con la ruta que se está vigilando en ese momento. Un huérfano real mantenido vivo por un hard link se sigue detectando, así que el guard permanece fail-closed.
También se corrigieron dos race conditions de la misma familia:
- La ruta de escritura ejecutaba su sonda de «los sidecars desaparecieron» antes de tomar su propio bloqueo, mientras que
close()desvincula los sidecars bajo ese bloqueo. Un escritor que competía con un apagado limpio se marcaba a sí mismo con la bandera pegajosa «WAL generation lost». Ahora la sonda se ejecuta dentro del bloqueo — once líneas. - Cuando la sonda del WAL quedaba indeterminada, el helper devolvía
"wal"como modo supuesto, ySessionDBhabilitaba su pool de lectura sin bloqueo sobre esa suposición. Si el archivo estaba en realidad en modo DELETE, el pool era incorrecto. Ahora hay que confirmar la cabecera; cuando no se puede, las lecturas hacen cola sobre el bloqueo del escritor.
4. Una fila mala no debería destruir toda la tabla
Dejando el bloqueo aparte, había un punto de avalancha: una sola fila mala podía tumbar un comando entero. Las formas vistas en producción incluyen un timestamp basura almacenado como TEXT, JSON malformado en model_config y un borrado masivo que superaba el techo de variables enlazadas de SQLite.
Las víctimas eran hermes sessions list, hermes sessions browse, la exportación de sesiones (JSONL / Markdown / HTML), hermes insights y sessions prune.
Ahora hay guards en ambos lados:
- Las lecturas pasan por
hermes_cli.timefmt.coerce_epoch(), que convierte una celda almacenada a segundos epoch dentro de una ventana sensata (de 1970 hasta aproximadamente 2103, dentro detime_tde 32 bits) o no devuelve nada — tras un WARNING que nombra el id de la sesión. La celda mala se renderiza como?oN/Ay el comando termina. - Las escrituras aplican la misma ventana: un timestamp fuera de ventana cae a la hora actual, así que Hermes no puede persistir una fila mala nueva.
- Los marcadores JSON se protegen con
json_validallá donde se leamodel_config. - El trabajo masivo trocea sus listas
INpara evitar «too many SQL variables», y la consulta N+1 del exportador ahora hidrata los mensajes en lotes de 900 ids.
5. Las sesiones ya no visitan otros profiles
Si corres más de un profile, dos de estas correcciones son directamente para ti:
- El backend de lanzamiento del desktop llamaba a
acquire()sin ruta, así que su primer acceso resolvíaHERMES_HOMEbajo el override de contexto que estuviera activo en ese instante. Si la ventana de override de otro profile estaba abierta, el handle con vida de proceso apuntaba a el almacén de ese profile para siempre. Ahora está fijado al home de lanzamiento. - El escaneo de fallback por id desnudo en
session_searchdesapareció. Antes, un fallo de búsqueda en tu propio almacén hacía que abriera la base de datos de todos los profiles y devolviera la transcripción completa del primer dueño — sin nombrar el profile. Ahora un miss es un miss, con una pista para pasarprofile=o@session:<profile>/<id>.
Borrar un profile también cierra los handles compartidos de este proceso antes de eliminar el directorio, lo que corrige el fallo WinError 32 (archivo en uso) en Windows.
6. Un índice de búsqueda dañado no es una transcripción dañada
Una más, muy práctica: un índice FTS5 corrupto dentro de state.db se reportaba como corrupción de todo el archivo, empujando a la gente hacia la ruta de recuperación pesada.
Ahora los errores se clasifican por procedencia: un fallo lanzado desde las tablas shadow messages_fts* se convierte en la causa de persistencia fts_index, y la superficie dice «búsqueda degradada, transcripción canónica intacta». Además:
hermes doctorresuelve los ids de árbol dañados mediantesqlite_master.rootpage, así que puede decirte si lo dañado es el índice o las tablas estructurales antes de recomendar una recuperación;hermes sessions repair --check-onlysale con código distinto de cero ante un almacén no sano, lo que lo hace scriptable;- la vía de recuperación maneja las formas en que de verdad llegan las bases de datos de incidentes reales: una cabecera de página 1 rasgada, filas NOT NULL fantasma, un borde de rowid dañado, una familia de índices CJK y tablas shadow huérfanas que deja atrás
.recover.
Qué deberías hacer
Actualiza a la última build (hermes update). Para revisar tu propia máquina, tres comandos cubren casi todo:
hermes sessions list --limit 5 # ¿la lista todavía se lee sin problemas?
hermes sessions repair --check-only # salida distinta de cero si el almacén no está sano
hermes doctor --fix # distingue daño de índice vs estructural y propone el siguiente paso
Si corres varios profiles, nombra el profile de forma explícita:
hermes -p worker doctor --fix
La conclusión
Lo que une todas estas correcciones es que «el bloqueo» nunca fue una sola cosa. Era autoridad de escritura innecesaria, varios procesos compitiendo por ser el único escritor y una falsa alarma que degradó el almacenamiento en silencio para siempre. Para los usuarios, el cambio más visible es simple: una base de datos establecida debería abrirse al instante, no hacerte mirar un prompt durante veinte segundos.
Para la ronda anterior de auto-reparación — detección, cuarentena, reparación — mira state.db se cura a sí mismo. Para entender por qué las sesiones de larga duración son el default equivocado y cómo la memoria se diseña alrededor de los límites de sesión, lee límites de sesión. Los comandos del día a día están en la referencia de hermes sessions, y este lote está en main por ahora — aparecerá en las notas de versión cuando salga la siguiente versión.