Volver a artículos
15 min de lectura

Cuando gRPC era inocente

I

Isaac Lacort Magán

11 abr 2026

Cuando gRPC era inocente: depurando corrupción de ficheros en un guardado asíncrono en Java

Por qué esta decisión de arquitectura importaba

Hace un tiempo construí un servicio gRPC centralizado para la gestión de ficheros. En lugar de permitir que los microservicios se intercambiaran documentos directamente entre sí, todos debían leer y escribir contra un único servicio dedicado. La idea era centralizar en un solo punto el control de acceso, las reglas de seguridad y las operaciones sobre ficheros, convirtiéndolo en la práctica en una especie de capa interna de object storage para el resto del sistema.

Elegí gRPC porque me ofrecía contratos estrictos, operaciones acotadas y límites de mensaje predecibles. Eso era importante porque el servicio podía recibir picos de carga elevados y yo necesitaba un flujo determinista con uso de memoria controlado. Para conseguirlo, diseñé las descargas como un stream por chunks con backpressure: el servidor leía el fichero de forma progresiva, cada mensaje contenía un chunk acotado, el cliente consumía esos chunks uno a uno y el fichero completo nunca se precargaba en memoria. En la práctica, esto me daba un modelo de transferencia controlado y seguro desde el punto de vista de memoria.

Los módulos del file service

Alrededor de este servicio también construí varios módulos cliente: un cliente estándar para aplicaciones Java 8, un cliente reactivo para aplicaciones Java 21 y un adaptador reactivo que transformaba el stream en tipos reactivos más cómodos de usar.

Estos módulos estaban pensados para aplicaciones no bloqueantes. El cliente se encargaba de la parte publisher-subscriber, mientras que el adaptador exponía una API más cómoda para el resto de la aplicación.

Además, tanto en subidas como en descargas añadí un checksum como último mensaje del stream. Ese checksum se calculaba de forma incremental en ambos lados a medida que se iban procesando los chunks, de modo que el receptor podía verificar que el contenido había llegado correctamente. En ese momento, todo parecía bastante sólido: memoria acotada, streaming, verificación por checksum y un contrato bien definido.

Escenario en el que apareció el problema

El fallo apareció cuando empecé a usar este cliente desde una API REST que tenía que descargar varios documentos dentro de la misma petición. Al principio todo parecía funcionar bien. Sin embargo, al repetir varias veces exactamente la misma petición, algunos de los documentos descargados aparecían corruptos.

Lo interesante del caso es que los fallos no eran aleatorios. Los mismos ficheros tendían a fallar bajo las mismas condiciones de presión. Si hubiera sido un problema genérico de transporte, lo normal habría sido ver corrupción en cualquier fichero, o al menos en ficheros con características similares. Pero no era eso lo que estaba observando. El patrón era demasiado consistente como para ignorarlo, así que empecé a investigar.

Depuré el flujo gRPC y no encontré nada sospechoso. Después lancé más pruebas de carga centradas específicamente en esos documentos problemáticos, y fue entonces cuando llegué a una conclusión importante: la corrupción no estaba ocurriendo durante la transferencia. Estaba ocurriendo en el momento de guardar el fichero.

Lo que demostró el checksum

Para confirmar esa sospecha, comparé el checksum recibido en el último mensaje gRPC con el checksum que calculaba el cliente mientras iba recibiendo el stream. Coincidían, y eso significaba algo muy importante: el documento estaba llegando correctamente a memoria.

Entonces añadí una validación más. Una vez que el fichero ya se había guardado en disco, recalculé el checksum sobre el fichero persistido. A veces ese checksum era distinto. Ese fue el hallazgo clave. La capa de transporte era correcta; la corrupción aparecía después de haber recibido el stream correctamente.

Lo que terminó de reforzar el diagnóstico fue que, cuando el checksum del fichero guardado difería, seguía siendo siempre el mismo checksum erróneo para el mismo documento en cada ejecución fallida. Eso apuntaba a una corrupción determinista bajo ciertas condiciones de carga, y no a una pérdida aleatoria de datos. Dicho de otra forma: el checksum del stream validaba la integridad de la transferencia, mientras que el checksum del fichero persistido validaba la integridad de la persistencia. El bug estaba entre esos dos momentos.

Arquitectura en ese momento

A alto nivel, la arquitectura era esta:

API REST
┌─────────────────────────────────┐
│ Aplicación reactiva             │
│ ┌─────────────────────────────┐ │
│ │ Adaptador reactivo          │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Cliente reactivo            │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘


         gRPC File Service


             Filesystem

El paso de guardado utilizaba una librería asíncrona de Java para mantener el pipeline no bloqueante. Sobre el papel parecía una solución razonable: los chunks llegaban en orden, se procesaban progresivamente y se escribían en asíncrono. En la práctica, sin embargo, ahí era exactamente donde vivía el bug.

Protocol Flow - one prepared chunk, demand-driven send
Server Read
Prepared (mem)
Client Receive
Async Write
Disk
Chunk N
Chunk N+1
Chunk N+2
WAIT: client asks next
send unlocks next read

Problema real

Los chunks estaban llegando a la capa de guardado en el orden correcto. El problema real era que las escrituras asíncronas no se estaban coordinando de una forma segura.

Al principio contemplé otra posibilidad: que la aplicación siguiera procesando el documento antes de que terminara la última escritura asíncrona. Eso habría sido la típica race condition provocada por falta de sincronización o por no esperar correctamente a la finalización de la operación. Pero lo descarté bastante rápido. Los documentos solo se procesaban cuando todas las descargas habían terminado, el tamaño final del fichero era correcto y, además, el patrón de corrupción era determinista y no aleatorio.

Eso dejaba en pie una única explicación. Para algunos documentos, el último chunk era mucho más pequeño que los anteriores. Bajo carga, ese chunk pequeño podía escribirse más rápido que el anterior. Si la persistencia dependía de una secuencia implícita tipo append, en lugar de una escritura posicional estricta o de una confirmación serializada de cada escritura, entonces el orden de finalización podía diferir del orden de llegada. En ese escenario, el fichero podía terminar corrupto incluso aunque el stream hubiera sido completamente correcto.

Esa fue la lección real: el problema no era que gRPC fallara, ni siquiera que el I/O asíncrono fuera malo por sí mismo. El problema era lanzar escrituras asíncronas de una manera que rompía las suposiciones de orden.

Bug Flow - enters async write without previous write confirmation
Server Read
Prepared (mem)
Client Receive
Async Write
Disk
Chunk N
Chunk N+1
Chunk N+2
overlap: write queue receives next chunk too early

Validación de la hipótesis

Para validar esa hipótesis, monté una prueba muy focalizada. Recibía los chunks en pares y, cuando el segundo chunk era más pequeño de lo normal, lo guardaba intencionadamente antes que el primero dentro de esa iteración.

El resultado fue exacto. El SHA-256 del PDF guardado coincidía con el mismo checksum erróneo que había visto en los casos reales. En otras palabras, había conseguido reproducir el patrón de corrupción de forma deliberada. En ese momento, el diagnóstico quedó claro: la transferencia era correcta, pero la persistencia podía comprometer los chunks en un orden efectivo incorrecto.

Por qué el problema no estaba en gRPC

Esta es, probablemente, la parte que más valor tiene de toda la historia. Cuando un fichero llega corrupto, es muy fácil culpar primero a la capa de transporte. En este caso, sin embargo, gRPC estaba haciendo exactamente lo que debía hacer. El stream llegaba bien, el checksum coincidía y los chunks se entregaban en orden.

La corrupción aparecía después, en la capa de persistencia del lado cliente. Así que el problema no era “gRPC + async” en general. El problema era un pipeline de streaming correcto alimentando un proceso de escritura asíncrona mal coordinado. Y esa diferencia importa, porque cambia tanto el diagnóstico como la solución.

Solución

Una vez que la causa real quedó clara, la solución fue bastante simple: el pipeline no podía empezar a escribir el siguiente chunk hasta que la escritura anterior hubiese sido completamente confirmada.

En la práctica, eso implicaba pasar de escrituras asíncronas solapadas a un flujo estrictamente coordinado. Después de recibir el chunk N, el cliente podía lanzar su operación de escritura, pero el chunk N+1 no podía avanzar a la etapa de persistencia hasta que esa escritura previa hubiese terminado correctamente. La regla pasó a ser muy simple: recibir, escribir, confirmar y solo entonces continuar.

Ese cambio preservó la corrección de extremo a extremo en todo el pipeline. También significó que el throughput pasaba a quedar limitado por la etapa más lenta de la secuencia —lectura en servidor, creación del chunk, envío, recepción en cliente y, finalmente, escritura en filesystem—, pero en este caso era el trade-off correcto. La integridad del fichero importaba más que el paralelismo sin control.

Fixed Flow - waits for send request and previous write confirmation
Server Read
Prepared (mem)
Client Receive
Async Write
Disk
Chunk N
Chunk N+1
Chunk N+2
WAIT: client asks next
WAIT: previous write confirmed

Conclusión

Este bug fue un dolor de cabeza, pero también dejó una lección muy útil. En sistemas distribuidos, que la capa de transporte sea correcta no garantiza que la persistencia también lo sea. Un stream puede ser completamente válido en memoria y aun así terminar corrupto en disco si los límites asíncronos no están bien diseñados.

Además, me recordó algo que cada vez valoro más en ingeniería: los patrones comunes, las librerías populares y las implementaciones “normales” no son verdades absolutas. Siempre hay que evaluarlas contra la arquitectura real, las condiciones reales de carga y las garantías reales que necesita el sistema.

Spring BootGRPCAsyncReactive