Saltar al contenido
White / wsuites

A fondo 3 min de lectura

Búsquedas cache-first en un bot orientado a eventos

Por qué las tareas de evento independientes y un camino de lectura cache-first fueron la forma correcta para un bot alpha en Rust, y qué cuesta esa arquitectura después.

Publicado

Asuka RS es un proyecto personal en Rust, en fase alpha: un bot de Discord orientado a eventos, con búsquedas cache-first, respuestas localizadas y soporte para 31 códigos de idioma. Esta nota trata de dos decisiones suyas que se generalizan mucho más allá de un bot, y de lo que cuestan.

Una tarea por evento, no un handler para todo

La forma ingenua de consumir un gateway es un único bucle con un match grande. Funciona hasta que una rama hace algo lento o entra en pánico, y entonces el bucle que atiende a todos los demás eventos es lo que se ha detenido.

La alternativa es que el bucle de recepción no haga casi nada salvo delegar:

while let Some(event) = shard.next_event(EventTypeFlags::all()).await {
    let Ok(event) = event else { continue };
    let ctx = ctx.clone();
    tokio::spawn(async move {
        if let Err(error) = handle_event(ctx, event).await {
            tracing::warn!(?error, "event handler failed");
        }
    });
}

De ahí salen dos propiedades. El fallo queda contenido: que un handler devuelva un error se registra y el siguiente evento no se ve afectado. Y el trabajo lento deja de bloquear al rápido, porque una tarea esperando a la base de datos no retiene el bucle de recepción.

El coste es real y conviene decirlo. Un spawn sin límite significa concurrencia sin límite: una ráfaga produce tantas tareas como eventos, y el orden entre eventos deja de estar garantizado. Para un alpha con tráfico a ritmo humano el intercambio está bien. A mayor escala se convierte en un pool acotado de workers y una cola, y eso hay que planificarlo, no descubrirlo por sorpresa.

Lecturas cache-first, y la invalidación que no puedes saltarte

La mayoría de los comandos de un bot necesitan las mismas piezas pequeñas de estado (las preferencias de una persona, la configuración de un servidor) en casi cada interacción. Leerlas de disco cada vez es trabajo desperdiciado, así que el camino de lectura mira primero una caché en memoria:

async fn user(&self, id: UserId) -> Result<User> {
    if let Some(user) = self.users.get(&id).await {
        return Ok(user);
    }

    let user = self.db.find_user(id).await?.unwrap_or_default();
    self.users.insert(id, user.clone()).await;
    Ok(user)
}

Lo interesante no es la lectura. Es que cada escritura tiene que invalidar o actualizar esa misma clave, y ahí es donde los diseños cache-first se rompen de verdad:

async fn set_locale(&self, id: UserId, locale: Locale) -> Result<()> {
    self.db.update_locale(id, locale).await?;
    self.users.invalidate(&id).await;   // no es opcional
    Ok(())
}

Si falta esa línea aparece el peor tipo de bug: alguien cambia un ajuste, la escritura funciona y el bot sigue comportándose como si no, de forma intermitente, según el nodo o la ventana de TTL que le toque. Escribe la invalidación en la misma función que la escritura, nunca en una “capa de caché” aparte que alguien pueda olvidar llamar.

Una caché acotada con TTL convierte una invalidación olvidada de corrupción permanente en una ventana acotada de datos obsoletos. Eso es una red de seguridad, no un diseño.

Flujo de proceso El camino de lectura. La arista discontinua es la que se paga, y la que la invalidación existe para mantener honesta.

Localización como búsqueda, no como bifurcación

31 códigos de idioma es la cifra en la que el formateo de texto deja de ser algo incidental. La regla que lo mantiene manejable: el texto que ve el usuario nunca se construye en el código del handler. El handler resuelve un idioma y pide una clave.

let locale = user.locale.unwrap_or(guild.locale);
let message = self.i18n.lookup(&locale, "command.ping.response");

El inglés es siempre el respaldo. Una traducción que falta degrada a un idioma que quizá no se prefiera, lo cual es incómodo; una traducción que falta y provoca un pánico o devuelve una cadena vacía es un bot roto. La cobertura entre esos 31 códigos puede ser incompleta (es el estado honesto de un alpha), y el respaldo es lo que hace esa incompletitud soportable.

Qué haría distinto

Las tareas de evento independientes fueron el modelo de concurrencia correcto para el MVP, e hicieron evidente el siguiente requisito en vez de esconderlo: en cuanto no puedes ver cuántas tareas hay en vuelo, necesitas herramientas operativas de verdad. Una API y un panel van antes de un lanzamiento más amplio, no después.

La lección general: elige primero el modelo de concurrencia que contiene el fallo, y trata la observabilidad que exige como parte de la misma decisión, no como algo que añadirás más tarde cuando duela.