Proyectos

Recetia: el 80 % del tiempo se va en traer datos

Recetia genera recetas cruzando 1.506 ingredientes, sus perfiles de sabor y olor, y las técnicas de cocina que les van bien. Este es el motor que hace ese cruce.

Producto propio, en desarrollo — sin clientes en producción
Traer datos
80% del tiempo
Cálculo puro
5% del tiempo
Accesos fuera de norma
3 → 0

Qué mide esta página

Lo que sigue son sus números reales, medidos ejecutándolo contra la base de datos, no estimados, y lo que encontré cuando lo audité pidiendo que se buscaran fallos en vez de confirmarlos.

Dos cifras distintas aparecen más abajo y conviene no confundirlas: el flujo completo de generación son 40 consultas, y la simulación del panel son 16. Son mediciones de cosas diferentes.

¿Qué es lo caro en un motor de cálculo?

No pensar. Ir a buscar.

Para proponer una receta hay que responder a muchas preguntas a la vez: a qué sabe cada ingrediente, qué técnica le conviene, qué recetas se parecen a lo que hay en la nevera, cuánto cuesta, cuánto tarda.

Escrito de la forma directa, cada pregunta va por su cuenta a la base de datos. Y muchas piden lo mismo: la lista de técnicas de cocina hace falta para calcular afinidad, para calcular compatibilidad, para ordenar los pasos y para comparar con recetas existentes. Cuatro veces la misma consulta.

El motor separa dos cosas que normalmente van mezcladas. Primero reúne todos los datos que harán falta, agrupando las peticiones repetidas en una sola. Después hace las cuentas, sin volver a tocar la base de datos.

¿Dónde se va el tiempo, exactamente?

Una ejecución completa se resuelve en unas nueve centésimas de segundo. Instrumentando cada fase por separado: traer datos, 74 ms, el 80 %. Cálculo puro, 4,7 ms, el 5 %. Orquestación, el resto hasta 92 ms, el 15 %.

Si el cálculo son cinco milisegundos de noventa y dos, optimizar fórmulas no sirve de nada: podrías hacer las cuentas el doble de rápido y nadie notaría la diferencia. Todo el margen está en cómo se piden los datos.

Por eso el motor está construido alrededor de esa separación, y no alrededor de los algoritmos. Es una decisión que solo se puede tomar habiendo medido: la intuición dice lo contrario.

El 80 % del tiempo se va en traer datos y el 5 % en calcular. Eso es lo que decide el diseño, no los algoritmos.

¿Cómo escala?

Medido, no extrapolado: el motor completo con volúmenes crecientes, tres ejecuciones cada uno, mediana. Con 1 ingrediente, 49 ms. Con 10, 74 ms. Con 50, 146 ms. Con 100, 217 ms. Con 200, 270 ms. Con 400, 403 ms.

Multiplicar el volumen por 400 multiplica el tiempo por diez. El número de consultas no crece con los ingredientes; lo que crece es el trabajo dentro de cada una. La curva no tiene rodilla en el rango medido.

¿Qué pasó al auditar la regla?

El motor tiene una norma central: los datos se piden en un sitio, y en la parte de cálculo no se toca la base de datos. Está escrita en la documentación, en los comentarios y en la guía de revisión.

No se cumplía en tres sitios. Uno de ellos había entrado meses antes, en una función nueva, haciendo exactamente lo que la norma prohíbe. Todos los tests seguían en verde.

Existía un test de presupuesto —contaba peticiones y fallaba si subían— y funcionaba. Pero contaba peticiones agrupadas, no consultas reales. Una petición con datos asociados cuenta como una y ejecuta dos. Así, un puñado de peticiones tapaba cuarenta consultas.

¿Por qué un test de propiedad no lo habría evitado?

Esta es la parte incómoda, y es la que más me llevo.

La conclusión fácil sería "faltaba un test". Es más fino que eso. Al escribir el test que comprueba la regla, con los datos de prueba que ya existían solo detectaba dos de los tres incumplimientos. Con una sola técnica de cocina en la tabla y sin encadenamientos, dos ramas del código nunca se ejecutan.

Es decir: aunque el test hubiera existido desde el principio, el fallo habría entrado igual, en verde. No por falta de test, sino porque los datos con los que corría no llegaban hasta ahí. Ese es el error que comete todo el que sí escribe tests.

La solución fue doble. Enriquecer los datos de prueba hasta que las tres ramas se ejecuten. Y añadir un segundo test que no depende de datos en absoluto: lee el código fuente de cada parte del motor y busca accesos a la base de datos donde no deben estar, incluidas las partes que ningún test recorre.

El estático se verificó inyectando una violación a propósito y comprobando que saltaba. Un test en el que no has visto fallar no sabes si funciona.

Test dinámico con datos pobres, ve 2 de 3. Con datos completos, ve las 3. Test estático, sin datos, ve incluso lo que nadie ejecuta.

¿Por qué después del arreglo hay más consultas, no menos?

De 39 a 40. Parece un empeoramiento y es lo contrario.

Esas consultas ya se estaban ejecutando. Lo que pasaba es que, al saltarse el sistema de agrupación, eludían el contador: no aparecían en ninguna métrica ni en ningún test. Al reconducirlas por donde corresponde se volvieron visibles.

El presupuesto interno subió de 21 a 24, y ese cambio quedó anotado con su motivo en el propio test. Un límite que se afloja sin explicación deja de ser un límite.

El balance completo: consultas SQL por ejecución, de 39 a 40, las mismas pero ahora contadas. Accesos fuera de norma, de 3 a 0. Columnas traídas por consulta, de 19 a 12. Reparto carga/cálculo, de 79/11 a 80/5. Tests en la suite, de 501 a 520, sin romper ninguno.

El caso de criterio: avisar en vez de denegar

El perfil de sabor se calcula ponderando cada ingrediente por su peso en gramos. Si el cliente no manda las cantidades, todos pesan igual: una pizca de azafrán cuenta como un kilo de patatas.

La reacción inmediata fue exigir las cantidades y rechazar la petición sin ellas. Correcto en el papel y roto en la práctica: los dos productos que consumen este motor no las envían hoy. Habrían quedado sin servicio.

La salida buena era otra. La petición se acepta, y la respuesta declara qué ponderación se aplicó realmente: por gramos, parcial o sin ponderar. Se consigue lo mismo —que nadie confunda un perfil ponderado con uno plano— sin dejar a nadie fuera.

Y de paso apareció algo que el aviso habría tapado: las cantidades llegaban del JSON con la clave en texto y se descartaban en silencio. La ponderación por gramos nunca había funcionado en esa ruta. Sin arreglarlo, el aviso habría dicho "ponderado por gramos" sobre un perfil plano, que es peor que no avisar.

¿Qué me llevo?

La documentación describe intenciones; el código, hechos. La arquitectura documentada hablaba de seis componentes y diecisiete orígenes de datos. El código tenía doce y veinte. Ninguna cifra era mentira cuando se escribió: nadie volvió a medirlas. Auditar es ejecutar, no leer.

Un test verde puede ser un test ciego. El de presupuesto existía y funcionaba, pero medía la unidad equivocada. El de propiedad, con datos pobres, veía dos de tres. Antes de confiar en un test, comprueba que falla cuando debe.

Lo correcto que rompe a todo el mundo no es lo correcto. Exigir las cantidades era defendible en abstracto y desastroso en concreto.

Nota sobre las cifras

Todos los números salen de ejecutar el motor contra la base de datos de desarrollo —1.506 ingredientes, 22 técnicas, 34 recetas— instrumentando cada fase. Los tiempos son la mediana de tres ejecuciones.

Dos condiciones que conviene declarar: dos tablas de afinidad están vacías, así que estos tiempos son un suelo y no un techo; y el corpus de recetas es pequeño, de modo que la curva de escalado describe el comportamiento con muchos ingredientes, no con muchas recetas.

Stack

  • Python 3.12
  • Django 5
  • PostgreSQL 16
  • pgvector
  • Celery
  • Redis