fixlog
Un parser zero-copy en Rust y un visor de terminal interactivo para logs del protocolo FIX que recorre millones de mensajes sin cargarlos en RAM ni asumir un único layout de log.
¿Qué es?
Una herramienta de línea de comandos y una TUI interactiva que leen logs FIX (Financial Information eXchange) tal como aparecen en producción: gigabytes de mensajes heterogéneos y en parte corruptos. Hace mmap del archivo, detecta el layout (separador de campos, prefijo de línea, encoding) y tokeniza cada mensaje en slices (tag, &[u8]) que toman prestados los bytes mapeados directamente —cero asignaciones, zero-copy desde el disco hasta la salida renderizada—. Una capa de diccionario resuelve los números de tag crudos a nombres de campo para FIX 4.4, FIXT.1.1 y FIX 5.0 / SP1 / SP2; encima se apoyan un índice paralelo con rayon y un mapa de hot-tags para filtrar, hacer tailing y analizar. Se distribuye como un único binario, fixlog, compilado desde un workspace Cargo de diez crates.
Contexto y Reto
Los logs FIX reales son hostiles para un lector ingenuo en tres ejes a la vez. Son grandes: una sesión de order management o de market data produce millones de mensajes y supera con facilidad varios gigabytes, así que cargar el archivo entero en un Vec no es opción. Son heterogéneos: cada motor y cada broker escribe un layout distinto —logs QuickFIX crudos delimitados por SOH (0x01), exportaciones renderizadas con | o ^, y líneas envueltas en prefijos de timestamp o logback—. Y son sucios: mensajes truncados, líneas en blanco y checksums rotos son lo normal, no la excepción. Un parser que asume un único separador y una única versión de FIX, carga el archivo de golpe y trata un checksum incorrecto como fatal malinterpreta la mayoría de los logs reales y se cae en la primera línea malformada. El reto era procesar logs de tamaño arbitrario en cualquiera de esos layouts, sin un flag de configuración por formato y sin morir ante entradas inválidas.
Decisiones
Hice que el detector de formato (sniffer), y no el usuario, decidiera el layout: inspecciona la cabecera del archivo para hallar el separador, el prefijo de línea y el fin de línea. El parser luego ignora el prefijo por completo y busca los límites de mensaje 8=FIX con la búsqueda acelerada por SIMD de memchr, de modo que los prefijos de timestamp/logback de longitud variable “simplemente funcionan” sin configurar nada. Mantuve el parser y el diccionario totalmente desacoplados: el parser emite números de tag crudos y slices de bytes prestados y no sabe nada de nombres de campo; el diccionario los resuelve eligiendo una cadena de versión a partir de BeginString + ApplVerID. Agregar una versión de FIX es soltar un esquema XML de QuickFIX en el build y registrar una cadena; nunca toca el parser.
Los desajustes de checksum y de body length son no fatales por diseño: un mensaje malo igual se emite y se loguea en debug, nunca provoca un panic. Para rendir a escala construí el índice con rayon, partiendo el buffer en bloques con propiedad explícita de los límites para que la salida paralela sea idéntica bit a bit a la secuencial, más un mapa secundario de hot-tags ((tag, valor) → [ordinales]) para que los filtros de igualdad cortocircuiten en lugar de escanear cada mensaje. Descarté un RoaringBitmap para ese mapa —más denso para archivos enormes, pero más lento de iterar y una dependencia extra— a favor de un HashMap<(tag, valor), Vec<u32>> simple.
La consolidación de órdenes a través de logs rotados es streaming, no basada en el índice: lee cada entrada (plana, .gz o stdin) en bloques de 1 MiB, arrastra el mensaje parcial final entre lecturas, fusiona las cadenas cancel/replace con un union-find sobre ClOrdID → OrigClOrdID (tags 11 → 41) y deduplica los fills por ExecID (tag 17).
Rendimiento
El parsing sostiene cerca de 1 GiB/s en mensajes largos de market data y ~240–310 MiB/s en logs de order management más cortos (la diferencia es el overhead por mensaje, no los bytes). La construcción del índice con rayon corre 2,4×–5,1× más rápido que en un solo hilo, alcanzando ~1,08 GiB/s en un buffer de 40 MiB; los buffers de menos de 1 MiB caen al camino secuencial porque el despacho de hilos domina por debajo de ese tamaño. El pushdown de hot-tags es la mayor ganancia individual: un filtro de igualdad como 35=D sobre un millón de mensajes bajó de ~477 ms (escaneo completo) a ~156 µs al intersecar listas de ordinales preindexadas —unas 3000×—. La TUI interactiva renderiza un frame en ~737 µs sobre un log de 1M de mensajes, ~22× por debajo del presupuesto de 16 ms para 60 fps. Reescribir el histograma temporal para extraer timestamps en paralelo con un escaneo estrecho de un solo tag lo llevó de ~573 ms a ~32 ms (−94%). Consolidar un corpus de ~540 MB de logs de órdenes rotados produce ~55k órdenes agregadas en menos de cinco segundos. El perfil de release está afinado para throughput (lto = true, codegen-units = 1).
Lecciones
La lección más filosa fue que el mismo nombre de campo puede significar dos números distintos. OrderConsolidated.avg_px es calculado (notional / cum_qty, donde el notional suma LastQty · LastPx sobre los fills), mientras que OrderEvent.avg_px es el valor crudo del tag 6 en el cable: confundirlos reporta en silencio el precio promedio equivocado. La segunda fue que los datos del mundo real rompen los algoritmos prolijos: un union-find de manual sobre ClOrdID → OrigClOrdID fusionaba órdenes no relacionadas porque algunos logs de broker usan un ancla placeholder (41=NONE); la solución fue un guardia que hace que una orden sea su propia raíz salvo que su OrigClOrdID se haya observado antes como un ClOrdID real. También dejé de confiar en buffer.len() como “fin de los datos”: la marca consumed del índice apunta justo después del último mensaje completamente parseado, así que un productor que vació medio mensaje hace que esa cola se vuelva a escanear en el siguiente append en vez de perderse. Si revisitara una cosa, sería el overlay :consolidated de la TUI: corre de forma síncrona en el hilo de primer plano sin caché, lo cual está bien para una inspección puntual pero debería volverse incremental para archivos vivos que crecen.
Quick Start
# compilar e instalar el binario `fixlog`
cargo install --path crates/fixlog-cli
fixlog sniff fixtures/synthetic/minimal_4.4.log # ¿qué layout tiene este log?
fixlog parse fixtures/synthetic/minimal_4.4.log --first 5
fixlog tui fixtures/real/fix44-om.log # visor interactivo (presioná ? para la ayuda)
fixlog grep live.log --filter "35=8 AND 55=AAPL" -F # tail -f, filtrado
fixlog orders consolidate logs/*.log logs/*.log.gz # agrega fills entre logs rotados