La ficha del empleado de unos clientes tardaba 70 segundos en cargar. Después del fix, la ficha comenzó a responder en menos de 1 segundo (~78 veces más rápido). La solución fue agregar una sola instrucción: nil. En este post abordaremos cómo un valor de retorno que nadie usaba se convirtió en el 99% del tiempo de carga de la página, y cómo usar herramientas de profiling, y no la intuición (optimizar la DB), fue lo que resolvió el problema.
Un cliente (luego más) reportó que la ficha de sus empleados era inusable y que, por lo tanto, no podía trabajar. Coincidentemente empezaron a aparecer timeouts en los logs, lo que dio la idea de que podría haber sido introducido por un cambio reciente. Al reproducir el caso con rack-mini-profiler, la request completa tardaba ~70 segundos, y el 99% de ese tiempo lo consumía un solo render: la cell del perfil del empleado, con 69,5 segundos y 569 queries SQL.
Las hipótesis iniciales apuntaban a los sospechosos de siempre: queries lentas, alguna feature nueva, algún cálculo pesado en la vista, etc. El profiler descartó al primero de inmediato, ya que sólo el 0,4% del tiempo era SQL. El 99,6% restante era Ruby puro.

El flamegraph obtenido con stackprof mostró algo inesperado:
Array#inspect, que terminaba en CanCan::Rule#inspect (37%) y ActiveRecord::Core::ClassMethods#inspect (25%).Es decir, casi todo el tiempo de la request no era trabajo de la página. Era Ruby serializando objetos que nadie iba a leer, y el GC limpiando la gran cantidad de strings que esa serialización alocaba.
Con la evidencia anterior, aún era difícil de saber en dónde estaba exactamente el problema, así como también era difícil comprender qué cambio lo introdujo, puesto que en Buk se hacen cientos de commits al día.
Luego de tener toda la evidencia y entender que el problema estaba en Ruby en vez de la base de datos, ocupamos Claude para ver si nos facilitaba la parte más abstracta que era el análisis sobre qué parte del código era la involucrada. Con lo anterior, pudimos concluir lo siguiente:
item de nuestros widgets de formulario terminaban en @items << {}, por lo que retornaban el array @items completo.<%= widget.item do %>...<% end %> llama .to_s sobre ese valor de retorno. Para un array, .to_s es Array#inspect: inspecciona recursivamente todo lo acumulado adentro.Nada de esto era visible leyendo el código. El problema solo existía en la combinación de los tres.
Por lo mismo, revertir no era una opción viable, ya que no era rápido detectar si había un commit culpable que deshacer y teníamos a muchos clientes bloqueados por la lentitud. El único camino era resolverlo.
nilLa corrección fue agregar nil como último valor de retorno de los métodos item:
def item(...)
@items << { ... }
nil
end
Con esto, <%= widget.item do %> retorna nil.to_s, que es un string vacío, sin gatillar ninguna inspección (Array#inspect). El contenido del bloque no se ve afectado, ya que el mecanismo de captura (capture(&block)) opera independientemente del valor de retorno, y el widget sigue renderizando los ítems desde su @items interno.
El cambio completo fueron 2 líneas en 2 archivos, omitiendo lo que sumó el hecho de agregarlo detrás de una feature flag para un rollout de menor riesgo.
Luego de aplicar los cambios y volver a analizar, se obtuvo lo siguiente:

inspect.@items << {} retorna el array completo aunque nadie lo pida. Si ese método se usa en ERB con <%= %>, ese retorno se serializa.inspect no es gratis. Sobre objetos que envuelven scopes de ActiveRecord, inspeccionar significa ejecutar SQL. Un simple to_s puede significar cientos de queries.Si te interesa trabajar en problemas como este, postula aquí.