Saltar a contenido

Incidente: SQLite en modo DELETE causando contención en Gitea (2026-08)

Ventana del incidente 2026-08-15, coincidiendo con el arranque del runner de Gitea Actions y el polling frecuente de tareas
Servicio afectado gitea (base de datos SQLite propia, gitea.db)
Impacto Errores intermitentes database is locked (SQLITE_BUSY) al escribir desde distintos componentes de Gitea (runner, web, cron) al mismo tiempo

Contexto: por qué Gitea usa SQLite acá

Este lab corre Gitea con DB_TYPE = sqlite3 (no Postgres/MySQL) — una sola base de datos, un solo archivo (gitea.db), sin servidor de base de datos aparte. Es la opción correcta para un lab de un solo nodo, pero SQLite tiene una particularidad importante: por defecto abre el archivo en modo DELETE (el modo "clásico" de journaling).

Qué es el modo DELETE y por qué molesta acá

En modo DELETE, cada transacción de escritura:

  1. Crea un archivo de journal temporal (gitea.db-journal).
  2. Toma un lock exclusivo sobre el archivo completo de la base de datos mientras dura la escritura.
  3. Borra el journal al terminar.

Ese lock exclusivo es el problema: mientras una escritura está en curso, nada más puede leer ni escribir — ni siquiera otro proceso que solo quiere leer una fila que no tiene nada que ver con la que se está escribiendo.

Por qué esto pega justo en Gitea Actions

El runner de Gitea Actions hace polling constante contra la API de Gitea (cada pocos segundos, según fetch_interval en su config.yaml) para preguntar "¿hay una tarea nueva?", y además escribe actualizaciones de estado de cada step casi en tiempo real mientras un job corre. Sumale los cron jobs internos de Gitea y el tráfico normal de la web UI. Con la base en modo DELETE, todo eso compite por el mismo lock exclusivo — cuantas más cosas escriben seguido, más chance de pisarse.

Síntoma

SQLITE_BUSY intermitente en los logs de Gitea, típicamente en operaciones de corta duración (actualizar el estado de un step, registrar un heartbeat del runner) que no deberían tener motivo para fallar por contención:

[E] can't update runner status: database is locked (5) (SQLITE_BUSY)

Causa raíz

Modo de journaling DELETE (el default de SQLite si no se configura nada distinto) combinado con el patrón de acceso de Gitea Actions: muchas escrituras cortas y frecuentes desde procesos distintos (runner, servidor web, cron), cada una compitiendo por un lock exclusivo de archivo completo.

Fix aplicado

Se cambió el modo de journaling a WAL (Write-Ahead Logging) en app.ini:

[database]
; journal_mode por defecto (DELETE) toma un lock exclusivo del archivo
; completo en cada escritura. WAL permite lectores concurrentes mientras
; hay una escritura en curso -- fix para "database is locked" bajo el
; polling frecuente de Actions + cron jobs (diagnosticado 2026-08-15).
SQLITE_JOURNAL_MODE = WAL

Por qué WAL resuelve esto (y qué NO resuelve)

En modo WAL, las escrituras no se aplican directo al archivo principal de la base: se van agregando a un archivo aparte (gitea.db-wal), y los lectores siguen leyendo del estado consistente más reciente sin bloquearse. La regla cambia de "una escritura bloquea todo" a "una escritura bloquea solo a otra escritura" — los lectores dejan de competir por el lock.

WAL reduce la contención, no la elimina

WAL sigue permitiendo una sola escritura a la vez — dos escrituras concurrentes todavía pueden chocar y devolver SQLITE_BUSY si el proceso no reintenta. En una corrida de este mismo playbook se vio, después de aplicado el fix, una única ocurrencia aislada de database is locked coincidiendo con un reinicio del contenedor de gitea (ver contención de recursos de Gitea/runner) — consistente con una escritura en curso justo en el momento del reinicio, no con que WAL no esté funcionando. Frecuencia bajó de "molesta con cierta regularidad" a "un caso aislado en varias horas de uso intensivo".

Validación

El cambio se aplicó directo en el app.ini montado como volumen del contenedor de gitea (no en un docker exec puntual) — persiste across reinicios del contenedor, confirmado leyendo el archivo montado después de un reinicio real del contenedor (ver contención de recursos), no solo aplicado en caliente y perdido al reciclar el contenedor.