
Reestructurando la forma en que Helium almacena y consulta los datos de la red
Migración de protobufs comprimidos con gzip a Apache Iceberg
Estamos migrando los datos de la red de Helium desde archivos protobuf comprimidos con gzip en S3 hacia tablas de Apache Iceberg, consultadas con Trino a través de un catálogo REST de Iceberg, con Cloudflare R2 como base de almacenamiento. Este proceso está en marcha en lugar de estar terminado: algunas tablas ya migraron por completo, otras se siguen escribiendo de ambas formas mientras validamos la nueva ruta contra la anterior. El cambio definitivo se completará el 1 de noviembre de 2026. Una nota aclaratoria sobre el alcance antes de continuar: esto corresponde únicamente a la red Mobile. IoT opera sus propios oráculos y sus propias tablas, y nada de lo que se describe a continuación les aplica.
El diseño anterior estaba optimizado para la escritura: los archivos en un bucket son económicos, sencillos e inmutables. Sin embargo, consultarlos era el gran problema.
La arquitectura anterior
Durante la mayor parte de la existencia de la red Helium Mobile, la respuesta a "¿a dónde van estos datos?" era: a un archivo protobuf comprimido con gzip en S3.
Un servicio de oráculo (ingest, el verificador de paquetes, el verificador de mobile) tomaba un mensaje de protobuf, lo serializaba y lo agregaba a un flujo de tramas delimitado por longitud. Ese flujo se comprimía con gzip a medida que se escribía, se rotaba mediante un temporizador o al alcanzar los 50 MB, y se cargaba en S3 bajo una clave como data_transfer_session_ingest_report.1782427072597.gz. El timestamp en milisegundos en el nombre del archivo era el único índice existente.
Ese diseño tiene grandes virtudes. Es económico: el almacenamiento de objetos es la opción duradera menos costosa que se puede adquirir, y el protobuf comprimido con gzip es compacto. Es simple: sin registro de esquemas, sin coordinador, sin clústeres que mantener activos; un escritor con permisos de PUT en un bucket es una implementación completa. Es inmutable por diseño, lo cual es fundamental cuando esos archivos constituyen la pista de auditoría detrás de la emisión de tokens en la blockchain. Y como los timestamps tienen una longitud fija, las claves se ordenan cronológicamente, por lo que "todo lo que ocurra después de este punto" se resuelve con una sola llamada ListObjectsV2 usando StartAfter.
El problema
Cuatro factores dificultaban el uso de esos datos, y en conjunto implicaban que nadie pudiera obtener una visión completa de la red sin antes tener que escribir un programa.
No era posible consultarlos directamente. Leer significaba procesar secuencialmente: listar claves a partir de una marca de tiempo, descargar cada objeto, descomprimirlo con gunzip, decodificar trama por trama y omitir lo que ya se había procesado. Cada consumidor debía implementar su propia copia de esa máquina de estados y su propio cursor persistente.
Una vista completa requería descargar todo. No existe la optimización de pushdown de predicados en un blob comprimido con gzip. Cualquier agregación, combinación (join) o filtrado sobre un mes de tráfico requería descargar y decodificar un mes entero de archivos; razón por la cual el verificador de mobile mantenía su propia copia en Postgres de lo que requería el cálculo de recompensas: un reflejo parcial y seleccionado a mano del data lake, inútil para responder cualquier pregunta que no se hubiera previsto.
La lectura generaba costos reales. S3 cobra por transferencia de salida (egress), y volver a procesar implica una lectura completa. Cuanto más se consultaban los datos, mayor era el costo; un incentivo negativo para quienes intentaban entender el comportamiento de la red.
El esquema residía únicamente en el código. Las definiciones de protobuf eran el esquema, por lo que nada en un archivo
.gzdentro de un bucket indicaba su contenido. Se necesitaban los tipos generados correspondientes, el decodificador correcto y saber qué mensaje contenía un prefijo determinado. Un analista con un cliente SQL no tenía forma alguna de acceder.
Por lo tanto, cualquier nueva pregunta —como cuántos datos transfirió un operador el martes pasado, qué hotspots emiten señales de vida (heartbeat) pero no transmiten tráfico, o cómo se distribuyen los rechazos por región— comenzaba como un ticket de soporte, requería desarrollar un programa en Rust y terminaba en una tabla que alguien debía mantener.
Mientras tanto, el volumen de datos seguía creciendo, al igual que el número de personas que necesitaban analizarlos: ingeniería de recompensas, desarrollo de negocio, alianzas con operadores y soporte. Ninguno de ellos quería programar un recolector de archivos. Lo que necesitaban era un SELECT.
flowchart LR
ORC[Oracles<br/>ingest, verifiers]
S3[(S3 bucket<br/>gzipped protobuf)]
POLL[File poller<br/>list, download, decode]
PG[(Postgres<br/>partial copy)]
OUT[Rewarder]
AN[Analyst]
ORC -->|PUT .gz| S3
S3 --> POLL
POLL -->|INSERT| PG
PG --> OUT
AN -.->|no way in| S3
Todo lo que está después del bucket son mecanismos de procesamiento secuencial, y Postgres solo almacena las columnas que algún consumidor decidió conservar. La línea punteada representa el problema principal: no existe un camino directo entre una consulta y los datos sin tener que escribir código primero.
Esa brecha es justamente lo que esta migración busca resolver.
Solución
La nueva arquitectura consta de tres componentes, y lo fundamental es que se mantienen modulares e independientes.
Apache Iceberg es el formato de tabla. Los datos siguen almacenándose como archivos inmutables en el almacenamiento de objetos —ahora en Parquet en lugar de protobuf con gzip— pero junto a ellos se guardan metadatos que describen las columnas, la partición y a qué instantánea (snapshot) confirmada pertenece cada archivo.
Un catálogo REST de Iceberg coordina el acceso a estas tablas. Es el servicio que responde qué tablas existen en el espacio de nombres de
hotspotsy dónde residen los metadatos actuales deheartbeats. Tanto los escritores como los lectores interactúan con él, asegurando la consistencia de los datos.Trino es el motor de consultas. Lee el catálogo, planifica la ejecución sobre los archivos Parquet y expone una interfaz SQL estándar para cualquier herramienta compatible con conexiones JDBC.
Ninguno de estos componentes se adueña de los datos. Los datos siguen siendo archivos almacenados en un bucket, una condición que nunca estuvo a discusión ya que es lo que permite que el proceso de recompensas sea auditable. El cambio radica en que ahora los archivos se autodescriben.
flowchart LR
ORC[Oracles<br/>ingest, verifiers]
OBJ[(Object storage<br/>Parquet)]
CAT[Iceberg REST catalog]
TR[Trino]
OUT[Services, analysts<br/>dashboards]
ORC --> OBJ
ORC -.->|commit snapshot| CAT
OBJ --> TR
CAT -.-> TR
TR --> OUT
Las líneas continuas representan el flujo de datos; las líneas punteadas, las confirmaciones de metadatos. Una vez que una tabla se migra, la infraestructura de reprocesamiento anterior desaparece: el verificador de mobile accede a sus datos de entrada de la misma manera que lo hace un analista, mediante SQL, y todos leen las mismas tablas confirmadas por los escritores.
Por qué Iceberg
Lo que los datos requerían era comportarse como una tabla: algo sobre lo que un motor de consultas pudiera planificar ejecuciones, y donde cualquier persona externa al equipo pudiera dirigir una consulta sin necesidad de recibir un decodificador previamente.
Iceberg ofrece exactamente eso. Los archivos en el bucket dejan de ser una pila desordenada y pasan a ser una tabla que declara su estructura: qué archivos forman parte de ella actualmente, cuáles son las columnas y cómo está particionada. Al apuntar un motor compatible con Iceberg al catálogo, este determina qué escanear y qué omitir, de forma totalmente autónoma.
Iceberg es un formato abierto de tablas diseñado para analítica de datos a gran escala. Tres de sus principales características clave fueron las que resolvieron nuestro problema.
El esquema es un recurso declarado, no un efecto secundario. Una tabla se define una sola vez en el código fuente —campos, nulidad, particionado— y esa definición es la que la crea en el catálogo. Así, una consulta detecta si una columna nunca es nula y no requiere un COALESCE, y un filtro en la columna de partición descarta archivos en lugar de escanear la tabla completa. Ambos aspectos ahora son legibles para las herramientas, no solo para quien conozca el código. Además, debido a que Iceberg rastrea las columnas por ID y no por posición, agregar, renombrar o reordenar una de ellas es solo un cambio de metadatos: sin reescrituras de tablas ni reaparición de datos obsoletos desde archivos antiguos.
Confirmaciones atómicas y múltiples escritores concurrentes. Una confirmación (commit) consiste en un intercambio del puntero de metadatos, no en un directorio de archivos que todos asumen que no deben modificar; por ende, un lector nunca verá datos a medias y múltiples motores pueden escribir en la misma tabla de forma concurrente. En el diseño anterior, un consumidor podía listar un objeto que aún se estaba cargando, y las soluciones para esto eran heurísticas: esperar, retrasar la marca de tiempo o reintentar.
Particionado oculto. Iceberg calcula automáticamente los valores de partición a partir de la columna indicada y omite los archivos que la consulta no necesita. Quien escribe el SQL no requiere conocer la estructura física de almacenamiento ni acordarse de agregar predicados adicionales para optimizar la consulta; además, la estructura puede modificarse a futuro sin tener que reescribir el historial ni romper las consultas ya desarrolladas.
Nada de esto es un desarrollo propietario. Es el propósito de Apache Iceberg; el trabajo de nuestro lado consistió en implementar la escritura y la lectura en él.
Esto es lo que hace posible la siguiente fase. Las tablas residen en R2, que no cobra costos de salida (egress), por lo que consultarlas no tiene costo para el usuario ni para nosotros. Cualquiera puede desplegar su propio Trino, apuntarlo al catálogo y consultar la red de forma directa: el tráfico transferido por un hotspot, las recompensas pagadas, o qué registros fueron rechazados y el motivo. Sin necesidad de desarrollar APIs, mantener pipelines de exportación ni recibir facturas por consultas de terceros. Para una red cuyas recompensas deben ser auditables, esta es la diferencia real entre publicar una cifra y publicar los datos que la respaldan.
Lo que Iceberg no hace de forma autónoma es su propio mantenimiento.
Compactación
Los escritores de streaming generan archivos pequeños. Cada confirmación escribe lo acumulado en la ventana del último lote, por lo que una tabla que recibe escrituras continuas termina el día con miles de objetos de unos cuantos megabytes en lugar de unos pocos archivos de gran tamaño.
Esto ralentiza y encarece las consultas. El motor debe abrir cada archivo que no pueda descartar y leer sus metadatos finales, por lo que el costo de la consulta escala según el número de archivos y no por el volumen real de datos. Además, en el almacenamiento de objetos, cada apertura representa una petición de red. Los metadatos crecen con la misma velocidad: cada instantánea (snapshot) conserva su manifiesto y los archivos antiguos permanecen allí hasta que se eliminen explícitamente.
La compactación es la solución, e Iceberg cuenta con soporte nativo para ello. El comando OPTIMIZE reorganiza los archivos pequeños de una partición en un menor número de archivos grandes, EXPIRE_SNAPSHOTS depura el historial que supera la ventana de retención y REMOVE_ORPHAN_FILES limpia los archivos residuales de las reescrituras. Ejecutamos estos tres procesos de forma programada por tabla: identificamos las tablas mediante el catálogo, seleccionamos las particiones con suficientes archivos pequeños para que valga la pena reescribirlos y dejamos intactas las particiones que aún reciben escrituras activas hasta que se estabilicen.
Estructura de datos
Tres espacios de nombres, cada uno de los cuales refleja una etapa del flujo que lo genera.
Tabla | Definición de fila | Particionada por |
| Sesión de descarga procesada: clave pública, operador, bytes de subida/bajada/recompensables, pagador |
|
| Consumo de Data Credits (DC) de un hotspot: |
|
| Historial de tickets de multiplicador HIP-150 recibidos, aceptados o rechazados; solo lectura para inserción |
|
| Señal de vida (heartbeat) validada: clave pública, posición, multiplicador de confianza de ubicación, origen de ubicación |
|
| Resultado de una prueba de velocidad (speedtest) |
|
| Promedio móvil de pruebas de velocidad de un hotspot |
|
| Bloqueo aplicado a un hotspot |
|
| Pago a un hotspot por período: |
|
| Pago a un proveedor de servicios por período |
|
| Emisiones no asignadas a ninguna entidad en un período |
|
Es indispensable filtrar siempre por la columna de partición. Este predicado permite a Iceberg descartar archivos; si se omite, la consulta leerá la tabla completa, sin importar qué tan delimitado parezca el resultado:
SELECT hotspot_pubkey, count(*)
FROM mobile.hotspots.heartbeats
WHERE received_timestamp >= TIMESTAMP '2026-09-21 00:00:00 UTC'
AND received_timestamp < TIMESTAMP '2026-09-22 00:00:00 UTC'
GROUP BY hotspot_pubkey
Debe ser obligatoriamente la columna de partición; filtrar por cualquier otra marca de tiempo en la misma fila no optimizará el escaneo, incluso si se mueven en paralelo. La excepción a esta regla es multiplier_ticket_history, que está segmentada por buckets de hotspot_pubkey, por lo que un filtro sobre la clave pública optimizará la consulta, mientras que un rango de fechas no lo hará.
Para consultar el esquema en tiempo real, consulte directamente al catálogo en lugar de este artículo: ejecute SHOW TABLES IN mobile.hotspots, DESCRIBE mobile.hotspots.heartbeats, o realice una consulta a mobile.information_schema.columns.
Conexión al servicio
El catálogo utiliza un punto de enlace (endpoint) REST estándar de Iceberg, por lo que cualquier cliente compatible con Iceberg puede consultar estas tablas. Todo cliente requiere la misma configuración inicial: el catálogo se encuentra en https://catalog.mobile.helium.io/api/catalog con el almacén de datos (warehouse) mobile, y el almacenamiento subyacente es de acceso anónimo. Estos valores son reales y están activos, no son ejemplos.
Nosotros utilizamos Trino, por lo que es la configuración que podemos garantizar. Para otras herramientas, los parámetros son homólogos según su propia sintaxis.
Trino
La documentación oficial de Trino es la referencia para esto: consulte Deploying Trino para la instalación del servidor, y la página del conector de Iceberg para conocer en detalle todas las propiedades descritas a continuación y otras opciones avanzadas.
# etc/catalog/mobile.properties
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://catalog.mobile.helium.io/api/catalog
iceberg.rest-catalog.security=NONE
iceberg.rest-catalog.warehouse=mobile
iceberg.rest-catalog.vended-credentials-enabled=false
iceberg.security=READ_ONLY
fs.native-s3.enabled=true
s3.region=us-east-1
s3.endpoint=https://helium.io
s3.path-style-access=false
s3.aws-access-key=anonymous
s3.aws-secret-key=anonymous
PyIceberg
La opción más ligera y que no requiere de un clúster activo. Defina el catálogo en el archivo ~/.pyiceberg.yaml:
catalog:
mobile:
type: rest
uri: https://catalog.mobile.helium.io/api/catalog
warehouse: mobile
s3.endpoint: https://helium.io
s3.region: us-east-1
s3.path-style-access: false
s3.access-key-id: anonymous
s3.secret-access-key: anonymous
O bien, defina los parámetros directamente en el código:
from pyiceberg.catalog import load_catalog
catalog = load_catalog("mobile", **{
"type": "rest",
"uri": "https://catalog.mobile.helium.io/api/catalog",
"warehouse": "mobile",
"s3.endpoint": "https://helium.io",
"s3.region": "us-east-1",
"s3.path-style-access": "false",
"s3.access-key-id": "anonymous",
"s3.secret-access-key": "anonymous",
})
table = catalog.load_table("hotspots.heartbeats")
df = table.scan(limit=10).to_arrow()
Considere la nomenclatura de las tablas: PyIceberg direcciona hotspots.heartbeats dentro del catálogo previamente cargado, mientras que Trino requiere el prefijo del nombre del catálogo: mobile.hotspots.heartbeats.
Spark
Defina las propiedades equivalentes como parámetros spark.sql.catalog.* en su archivo spark-defaults.conf o como flags del tipo --conf:
spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
spark.sql.catalog.mobile=org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.mobile.type=rest
spark.sql.catalog.mobile.uri=https://catalog.mobile.helium.io/api/catalog
spark.sql.catalog.mobile.warehouse=mobile
spark.sql.catalog.mobile.io-impl=org.apache.iceberg.aws.s3.S3FileIO
spark.sql.catalog.mobile.s3.endpoint=https://helium.io
spark.sql.catalog.mobile.s3.region=us-east-1
spark.sql.catalog.mobile.s3.path-style-access=false
spark.sql.catalog.mobile.s3.access-key-id=anonymous
spark.sql.catalog.mobile.s3.secret-access-key=anonymous
Posteriormente ejecute spark.sql("SELECT * FROM mobile.hotspots.heartbeats LIMIT 10"). Asegúrese de incluir las dependencias iceberg-spark-runtime e iceberg-aws-bundle en el classpath; sin el paquete de AWS, el catálogo resolverá las consultas pero las lecturas fallarán en la capa de almacenamiento.
Algunas consideraciones clave para evitar inconvenientes durante la implementación, independientemente del cliente de su elección:
Ningún dato de conexión es confidencial o privado.
iceberg.rest-catalog.security=NONEestablece que el catálogo no requiere autenticación y las credenciales de almacenamiento son literalmente la cadena de textoanonymous. No hay tokens que solicitar, rotar ni omitir en sus sistemas de control de versiones.warehouseno corresponde al nombre del bucket. Define el nombre del almacén tal como lo identifica el catálogo REST. Definir este parámetro de forma errónea permitirá una conexión exitosa pero no listará ninguna tabla.Las credenciales delegadas (vended credentials) están desactivadas. El catálogo no provee credenciales de almacenamiento por tabla, por lo que Trino utiliza los parámetros
s3.*definidos arriba para cada tabla con la que interactúa.Múltiples catálogos en un solo motor. Agregar un segundo archivo
.propertiesrepresenta un segundo catálogo, y Trino permite realizar consultas combinadas (joins) entre ambos. De esta forma, puede integrar este catálogo con sus datos internos y consultar ambos en una sola sentencia SQL.El catálogo cuenta con límite de peticiones (rate-limiting). Que el acceso sea abierto no significa que sea ilimitado. Las solicitudes están controladas, por lo que peticiones masivas y concurrentes recibirán respuestas de rechazo.
Las posibilidades de esta migración
El objetivo final nunca fue únicamente implementar Iceberg. Se buscaba hacer que los datos de la red fueran fáciles y económicos de consultar. La información de la red ahora es accesible a través de SQL estándar sobre una conexión JDBC, compatible con cualquier herramienta de Business Intelligence o notebooks que utilice actualmente. El único costo operativo de realizar consultas es mantener activa su instancia de Trino; las consultas de lectura no generan costos adicionales. Esta facilidad de integración permite desarrollar aplicaciones, tableros de control o estadísticas públicas a partir de los datos de la red en cuestión de horas en lugar de requerir un proyecto complejo.
Determinar cuántos datos transfirió cada operador el mes pasado, qué hotspots registran actividad pero no transmiten tráfico, cuáles fueron las ganancias detalladas de un nodo o qué sitios aprobados no han transmitido datos se resuelve ahora con una consulta SQL a tablas públicas en las que cualquiera puede configurar su propio motor analítico.


