Back to 0fee
0fee

Condiciones de carrera WAL y lecciones de SQLite

Las condiciones de carrera del modo WAL que afectaron la base de datos SQLite de 0fee.dev y que finalmente nos empujaron a PostgreSQL. Por Juste A. Gnimavo.

Thales & Claude | March 30, 2026 9 min 0fee
EN/ FR/ ES
sqlitewalrace-conditionsdatabaseconcurrency

El modo Write-Ahead Logging (WAL) de SQLite se promociona como la solucion a las limitaciones de concurrencia de SQLite. En lugar de bloquear toda la base de datos para escrituras, el modo WAL escribe los cambios en un archivo de log separado. Los lectores pueden continuar leyendo la base de datos principal mientras un escritor agrega al log. Suena perfecto. En la practica, introdujo condiciones de carrera sutiles que tomaron multiples sesiones diagnosticar y fueron finalmente una de las razones principales por las que migramos a PostgreSQL.

Este articulo documenta los errores relacionados con WAL que encontramos, los parches que aplicamos y por que esos parches nunca fueron del todo suficientes.

Como funciona el modo WAL

En el modo de journal predeterminado, SQLite bloquea el archivo de base de datos durante las escrituras. Cualquier lector concurrente se bloquea hasta que la escritura se complete. El modo WAL cambia esto:

Archivo de base de datos principal (.db)
    |
    +-- Archivo WAL (.db-wal) -- log secuencial de cambios no confirmados
    |
    +-- Archivo SHM (.db-shm) -- memoria compartida para coordinacion

Operacion de escritura:
  1. Agregar cambios al archivo WAL
  2. Los lectores con conexiones antiguas ven el archivo .db principal (pre-cambio)
  3. Cuando WAL alcanza un umbral, el checkpoint fusiona WAL de vuelta al .db

La propiedad clave: las nuevas conexiones abiertas despues de una escritura pueden todavia ver datos obsoletos si se conectan antes de que el WAL sea checkpointed. El archivo de base de datos principal solo se actualiza durante operaciones de checkpoint.

El error get_provider() vs. get_instance()

La sesion 060 revelo uno de los errores mas confusos que encontramos. El registro de proveedores tenia dos metodos: get_provider() (que devolvia una clase) y get_instance() (que devolvia una instancia inicializada). Algunas rutas de codigo llamaban a get_provider() esperando una instancia y obtenian una clase, llevando a errores TypeError: 'type' object is not callable.

Pero el error era intermitente. Funcionaba a veces y fallaba otras. La conexion WAL explicaba por que:

python# El registro de proveedores cargaba proveedores de la base de datos al arranque
class ProviderRegistry:
    def __init__(self):
        self._providers = {}
        self._instances = {}

    async def load_providers(self):
        """Cargar todas las configuraciones de proveedores de la base de datos."""
        # Esta consulta podria ver datos obsoletos si WAL no ha hecho checkpoint
        providers = await db.execute(text("SELECT * FROM providers"))
        for provider in providers:
            self._providers[provider.name] = load_provider_class(provider.type)
            self._instances[provider.name] = self._providers[provider.name](
                config=provider.config
            )

    def get_provider(self, name: str):
        """Devuelve la CLASE del proveedor."""
        return self._providers.get(name)

    def get_instance(self, name: str):
        """Devuelve una INSTANCIA inicializada del proveedor."""
        return self._instances.get(name)

Cuando un nuevo proveedor era agregado a la base de datos en una conexion, y el registro se recargaba en otra conexion, el modo WAL podia servir el estado antiguo de la base de datos. El registro cargaria sin el nuevo proveedor. Las llamadas posteriores a get_instance("new_provider") devolverian None, causando fallas posteriores.

La correccion inmediata fue usar get_instance() consistentemente:

python# ANTES: Uso mixto
provider_class = registry.get_provider(provider_name)  # Obtiene clase
result = provider_class.process(...)  # A veces funciona, a veces TypeError

# DESPUES: Uso consistente
provider = registry.get_instance(provider_name)  # Obtiene instancia
if not provider:
    raise HTTPException(400, f"Provider '{provider_name}' not found or not configured")
result = await provider.process(...)

PRAGMA wal_checkpoint(PASSIVE) despues de commits criticos

La sesion 072 introdujo checkpointing WAL explicito despues de operaciones donde las lecturas obsoletas serian peligrosas:

python# database.py
async def checkpoint_wal():
    """Forzar un checkpoint WAL para sincronizar datos al archivo principal de base de datos."""
    async with get_db_connection() as conn:
        await conn.execute(text("PRAGMA wal_checkpoint(PASSIVE)"))

# Usado despues de operaciones criticas async def create_transaction(data: dict) -> Transaction: transaction = Transaction(**data) db.add(transaction) await db.commit() BLANK # Forzar checkpoint para que los manejadores de webhook vean la transaccion await checkpoint_wal() BLANK return transaction ```

Identificamos seis puntos criticos de commit que requerian checkpointing:

OperacionPor que se necesita checkpoint
Creacion de transaccionLos manejadores de webhook deben ver la transaccion inmediatamente
Actualizacion de estado de transaccionLas consultas del panel deben reflejar el nuevo estado
Configuracion de proveedorLa recarga del registro de proveedores debe ver nuevas configs
Creacion de clave APIEl middleware de autenticacion debe validar nuevas claves
Creacion de appEl motor de enrutamiento debe ver nuevas configuraciones de app
Creacion de usuarioEl callback OAuth debe encontrar al usuario recien creado

¿Por que PASSIVE y no FULL?

SQLite ofrece tres modos de checkpoint:

sqlPRAGMA wal_checkpoint(PASSIVE);   -- Checkpoint todo lo posible sin bloquear
PRAGMA wal_checkpoint(FULL);      -- Checkpoint todo, bloqueando escritores
PRAGMA wal_checkpoint(TRUNCATE);  -- Checkpoint y truncar archivo WAL

Se eligio PASSIVE porque no bloquea operaciones concurrentes. Hace checkpoint de todas las paginas que no estan actualmente en uso por lectores o escritores activos. En la mayoria de los casos, esto sincroniza todas las escrituras recientes. FULL garantizaria sincronizacion completa pero bloquearia todo otro acceso a la base de datos durante el checkpoint -- inaceptable para un servidor web manejando solicitudes concurrentes.

La columna updated_at invalida

Durante la depuracion de WAL, descubrimos que la columna updated_at en varios modelos no se estaba configurando correctamente. El trigger ON UPDATE de SQLite se comportaba diferente de lo esperado con el modo WAL:

python# El problema: updated_at se quedaba como la hora de creacion original
class Transaction(Base):
    updated_at = Column(DateTime, onupdate=func.now())
    # En SQLite con WAL, el trigger onupdate a veces se disparaba
    # contra una version obsoleta de la fila, produciendo marcas de tiempo incorrectas

La correccion fue establecer updated_at explicitamente en el codigo de la aplicacion en lugar de depender de triggers a nivel de base de datos:

pythonasync def update_transaction_status(tx_id: str, new_status: str):
    transaction = await get_transaction(tx_id)
    transaction.status = new_status
    transaction.updated_at = datetime.utcnow()  # Explicito, no basado en trigger
    await db.commit()
    await checkpoint_wal()

Correcciones de sincronizacion no bloqueantes

El desafio central con el modo WAL es que no hay forma confiable de garantizar que un lector vea las ultimas escrituras sin coordinacion explicita. Implementamos varios patrones para sortear esto:

Patron 1: Leer despues de escribir en la misma conexion

python# Usar la MISMA conexion para escritura y lectura posterior
async def create_and_return_transaction(data: dict) -> Transaction:
    async with get_db_connection() as conn:
        # Escribir
        await conn.execute(
            text("INSERT INTO transactions (...) VALUES (...)"),
            data
        )
        await conn.commit()

        # Leer en la misma conexion -- garantizado ver la escritura
        result = await conn.execute(
            text("SELECT * FROM transactions WHERE id = :id"),
            {"id": data["id"]}
        )
        return result.fetchone()

Patron 2: Reintento con retroceso exponencial

pythonasync def get_transaction_with_retry(tx_id: str, max_retries: int = 3) -> Transaction:
    """Reintentar si WAL no ha sincronizado aun."""
    for attempt in range(max_retries):
        transaction = await get_transaction(tx_id)
        if transaction:
            return transaction

        if attempt < max_retries - 1:
            await asyncio.sleep(0.1 * (2 ** attempt))  # 100ms, 200ms, 400ms

    raise HTTPException(404, f"Transaction {tx_id} not found")

Patron 3: Cache de escritura directa

python# Para datos accedidos frecuentemente (proveedores, monedas),
# mantener un cache en memoria actualizado sincronicamente en escrituras
class WriteThrough:
    def __init__(self):
        self._cache = {}

    async def write(self, key: str, value: any):
        # Escribir a base de datos
        await db.execute(...)
        await db.commit()

        # Actualizar cache inmediatamente (evitar retraso WAL)
        self._cache[key] = value

    def read(self, key: str) -> any:
        # Leer del cache (siempre actualizado)
        return self._cache.get(key)

Por que esto nos empujo hacia PostgreSQL

Cada correccion que aplicamos era un parche sobre una limitacion fundamental. El modo WAL de SQLite esta diseñado para escenarios de una sola aplicacion (apps moviles, software de escritorio) donde un proceso posee la base de datos. Un servidor web con multiples conexiones concurrentes empuja el modo WAL mas alla de su diseño.

Los problemas que encontramos:

ProblemaSolucion SQLiteSolucion PostgreSQL
Lecturas obsoletas despues de escriturasCheckpoint PRAGMA explicitoMVCC -- los lectores siempre ven el estado confirmado
Un escritor a la vezEscrituras serializadas con timeoutsMultiples escritores concurrentes
Conexion ve datos obsoletosLeer-despues-de-escribir en misma conexionCualquier conexion ve los ultimos datos confirmados
Problemas con trigger updated_atConfiguracion explicita de marca de tiempoEjecucion confiable de triggers
Bloqueo de checkpointModo PASSIVE (sincronizacion incompleta)No se necesita checkpointing

El Control de Concurrencia Multi-Version (MVCC) de PostgreSQL elimina cada problema relacionado con WAL que encontramos. Cada transaccion ve una instantanea consistente de la base de datos. Las escrituras son inmediatamente visibles para nuevas transacciones (despues del commit). No hay archivo de log separado que necesite sincronizacion periodica.

Lo que aprendimos

El modo WAL no hace de SQLite una base de datos concurrente. Mejora la concurrencia de lectura, pero el modelo fundamental sigue siendo de escritor unico. Para cualquier aplicacion que necesite escrituras concurrentes -- lo que incluye todo servidor web -- el modo WAL es una mitigacion, no una solucion.

Los errores intermitentes son los peores errores. Los problemas relacionados con WAL aparecian aleatoriamente. Una prueba pasaba 9 de 10 veces y fallaba en la decima. Esto los hacia extremadamente dificiles de reproducir y diagnosticar. Cuando veas fallas intermitentes con SQLite, sospecha de lecturas obsoletas de WAL.

El checkpointing explicito es un code smell. Cuando el codigo de tu aplicacion necesita llamadas PRAGMA wal_checkpoint() despues de operaciones criticas, estas peleando contra el motor de base de datos. La base de datos deberia garantizar consistencia sin intervencion a nivel de aplicacion.

SQLite es fenomenal para lo que fue diseñado. Es la mejor base de datos embebida del mundo. Pero una plataforma de pagos con solicitudes API concurrentes, manejadores de webhook y workers en segundo plano no es un caso de uso de base de datos embebida. Elegir la herramienta correcta para el trabajo correcto importa mas que cualquier cantidad de soluciones creativas.

Las condiciones de carrera WAL nos costaron aproximadamente 15 horas a traves de las sesiones 060, 072 y varias sesiones de depuracion intermedias. La migracion a PostgreSQL (sesion 081) elimino todos estos problemas permanentemente.


Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles