Saltar a contenido

Ingesta: Edge → Cloud

Responde: ¿qué garantiza que un dato que salió de la planta llegue a la nube una vez y solo una vez?

Es la única frontera del sistema que atraviesa internet, la única con una credencial propia por dispositivo, y la única donde los dos extremos tuvieron que acordar una tabla de códigos. También es la que más consecuencias tiene si se malinterpreta: un código de error equivocado borra mediciones para siempre.

La decisión que la gobierna es SYS-ADR-002.

La forma

Un POST con un lote de eventos. Nada más: no hay websockets, no hay cola de mensajes, no hay suscripciones.

POST /api/v1/consumers/events
X-Api-Key: <clave del dispositivo>
Idempotency-Key: <sha256 de los eventId del lote>
Content-Type: application/json

{ "events": [ { "eventId", "machineId", "ts", "seq", "kind", "schemaVersion", "payload" } ] }

El Pi no dice a qué cliente pertenece, y no podría. No manda X-Tenant-ID: el Cloud resuelve el tenant_id y el device_id a partir de la clave, del lado del servidor. El machineId del cuerpo es el único dato de identidad que viaja, y solo se acepta si coincide con el del dispositivo al que pertenece la clave; si no, el evento se rechaza. Un Pi comprometido no puede escribir en nombre de otra máquina.

Qué va en el payload

El sobre es fijo; el contenido no se valida nunca. Desde el 2026-10-01 el Edge pone ahí el identificador semántico:

{
  "aasPath": "Operativos/Pesada/peso",
  "value": 12.34,
  "unit": "kg",
  "valueType": "xs:float"
}

Va dentro del payload y no como campo de primer nivel por dos razones: el campo AASPath del evento está marcado json:"-" en el Edge, y el eventId es un sha256 irreversible, así que sin esto el Cloud recibe un número sin forma de saber a qué propiedad corresponde. payload admite propiedades libres, así que la clave no versionó el esquema ni obligó a coordinar despliegues.

Del lado del Cloud eso se sostiene con un índice sparse sobre payload.aasPath: un evento que no la traiga se persiste igual y simplemente no entra al índice.

El aasPath es parte de la identidad del dato

El eventId es sha256(machineId | aasPath | ts_nano), así que dos formas del mismo aasPath producen dos identidades distintas y no se deduplican entre sí.

Es la razón por la que la forma canónica tuvo que decidirse en una ADR de sistema y no en cada componente: SYS-ADR-009 colapsa el segmento de colección vacío, y lo que el payload lleva para las dos propiedades de Alarmas es Alarmas/paradaEmergencia y Alarmas/alimentacionTolva, con una sola barra. Lo almacenado bajo la forma de tres segmentos es invisible para una consulta por la canónica.

Los códigos, que no son intercambiables

Esta es la parte de la frontera que no se puede deducir leyendo un solo repositorio, y la que justifica que haya una página acá.

Un 200 con rechazos parciales y un 400 no son dos formas de decir lo mismo. El Edge reacciona distinto a cada uno, y la diferencia es si un dato se reintenta o se pierde:

Lo que responde el Cloud Lo que hace el Edge Efecto
200 + errors[].code = DUPLICATE ACK del evento Correcto: es el resultado esperado de un reintento, no un error
200 + INVALID_SCHEMA marca DEAD Irreversible. No se reintenta nunca más
200 + VALIDATION_FAILED marca DEAD Ídem
200 + STORAGE_UNAVAILABLE vuelve a PENDING y reintenta Cae en el default del Edge, que es reintentar
200 + un código que el Edge no conoce vuelve a PENDING y reintenta El default es seguro por diseño
400 o 422 todo el lote a DEAD Hasta 1000 mediciones perdidas de una vez
403 reintenta todo el lote Asume rotación de clave en curso
429 espera y reintenta todo el lote Respeta Retry-After
5xx, timeout, error de transporte reintenta todo el lote

Los dos extremos coinciden: las constantes del dominio del Cloud citan explícitamente el archivo del Edge que las consume, y el Edge clasifica por esos literales.

Tres invariantes de diseño del Cloud existen solo para que esta tabla no se rompa:

  • La infraestructura caída es 500, jamás 400. Un 400 convertiría una caída de diez minutos en pérdida permanente de datos.
  • Los problemas de un evento van por 200 + errors[], nunca por un código de estado. El 400 queda reservado a requests rotos a nivel de sobre: cuerpo ilegible, JSON malformado, events vacío o por encima del máximo.
  • El índice de errors[] es la posición en el array original, no en ningún array filtrado. El Edge usa ese número para elegir qué fila de su outbox marcar como DEAD: un desfasaje de uno mata eventos sanos y da por buenos los rotos.

Esa última es la razón por la que el Cloud deserializa los eventos de a uno ([]json.RawMessage en vez de una lista tipada): si un solo evento trae un ts numérico, con una lista tipada fallaría el parseo del cuerpo entero, habría que devolver 400, y el Edge mandaría los 1000 eventos a DEAD por culpa de uno.

Qué se acepta aunque no se entienda

Dos casos donde el Cloud acepta de más a propósito, y los registra para que no pasen desapercibidos:

Caso Qué hace Por qué
kind fuera de metric / alarm / heartbeat acepta, persiste y marca unknown_kind Rechazar mandaría el evento a DEAD para siempre la primera vez que un Pi hable un kind nuevo antes de que el Cloud se despliegue
schemaVersion mayor que la máxima conocida (1) acepta, persiste y marca schema_version_ahead Aceptar de más es reversible: se filtra o se borra después. Rechazar de más no lo es

Las dos marcas salen por métrica y por log. Es la única señal de que el Edge quedó más adelante que el Cloud desplegado, un orden de despliegue que este proyecto ya vivió.

La idempotencia: dos capas, y solo una decide

Capa Qué es Qué garantiza hoy
El índice único (tenantId, eventId) un índice de MongoDB Toda la idempotencia del sistema
El header Idempotency-Key sha256 de los eventId del lote, determinístico Nada. Se registra en el log y no decide

Esa segunda fila contradice la descripción del contrato, que dice que si el Cloud ya procesó la clave devuelve el mismo resultado. No lo hace: el handler la trunca a 64 caracteres, la loggea y sigue. Es una decisión consciente del Cloud —una caché de respuestas sería una segunda fuente de verdad con casos borde propios, para un beneficio acotado— pero quien lea el contrato del lado del Edge va a creer que existe una garantía de lote que no existe.

No cambia nada en la práctica, porque el reintento del Edge es byte a byte el mismo lote y cada evento se deduplica solo. Pero importa saber cuál de las dos capas sostiene el sistema: si el índice único no existe, cada reintento duplica mediciones en silencio. Y ese índice es precisamente lo que el CI no cubre.

Dos detalles del índice que explican por qué es compuesto y no simple:

  • La clave es (tenantId, eventId), no eventId solo. El eventId no lleva el tenant adentro, y el machineId solo es único por tenant, así que dos clientes distintos pueden generar el mismo eventId de forma legítima. Lo fija SYS-ADR-002.
  • La inserción es ordered: false, y eso no es una optimización: con ordered: true el primer duplicado abortaría el resto del lote, y en un reintento del Pi —donde el primer evento casi siempre ya existe— se perderían los 999 siguientes.

Los límites, y el único que discrepa

Límite Edge Cloud Contrato escrito
Eventos por lote 1000 (BATCH_MAX_SIZE) 1000 (INGEST_MAX_EVENTS) 1000 ✅
Bytes por lote 2 MiB (BATCH_MAX_BYTES) 4 MiB (INGEST_MAX_BODY_BYTES) 2 MB ⚠️
Peticiones por segundo 200 (CLOUD_MAX_RPS) 200 (INGEST_RATE_LIMIT_RPS) 200 ✅
Ráfaga 1000 (CLOUD_BURST) 1000 (INGEST_RATE_LIMIT_BURST) 1000 ✅

La discrepancia de bytes es inocua en el sentido que importa —el emisor es más estricto que el receptor, así que nunca se corta un lote válido— pero el contrato escrito describe al Edge, no al Cloud. Si alguien subiera BATCH_MAX_BYTES del Pi confiando en el número documentado, descubriría que el Cloud acepta el doble; si alguien bajara el del Cloud a 2 MiB confiando en lo mismo, no rompería nada hoy.

El tope de bytes del Cloud se aplica antes de parsear, con un lector limitado: un cuerpo de 2 GB no puede consumir memoria mientras se deserializa.

El límite de tasa falla abierto en los dos extremos

Extremo Cómo limita Qué pasa si su dependencia no está
Edge token bucket en proceso, antes de cada envío No tiene dependencia externa
Cloud token bucket distribuido en Redis, por key_id y en Lua para que sea atómico entre réplicas Sin Redis no limita nada

El fallo abierto del Cloud es deliberado y está en el registro de pendientes como una decisión de seguridad implementada y nunca escrita: un Redis caído no puede cortar la ingesta, porque el costo de rechazar datos reales es mayor que el de no limitar por un rato. Ver riesgos aceptados.

El limitador va después de resolver la clave, no antes, para limitar por dispositivo y no por IP: todos los Pi de una misma planta pueden salir por la misma dirección.

Qué hace el Edge cuando el Cloud no está

backoff = max( exponencial(1s → 60s, ×2, jitter ±20%), Retry-After )

El Retry-After no es decorativo: el Cloud lo pone en cada 429 con los segundos exactos que calcula el token bucket, y el Edge lo lee y espera al menos ese tiempo. Si el backoff acumulado pide más, gana el backoff: se respeta al servidor cuando pide más, nunca cuando pide menos.

Los reintentos no tienen tope. El corte lo pone el forwarder, no la librería, y hoy no hay corte: un lote retriable se reintenta indefinidamente. Lo que sí tiene tope es el outbox.

Umbral Qué pasa
OUTBOX_MAX_ROWS = 100 000 filas El Edge deja de encolar mediciones nuevas y lo expone como outboxSaturated en /status. No descarta lo ya persistido
OUTBOX_TTL_DEAD = 720 h (30 días) Las filas DEAD se purgan. Es el único TTL implementado: no existe el pase PENDING → DEAD por antigüedad
ACK La fila se borra de inmediato. El outbox solo retiene PENDING y DEAD

Con el outbox saturado, el dato no se pierde por retención

Una decisión del Edge plantea que si el Cloud está caído más tiempo que la retención de InfluxDB, los puntos que esperan se pierden. En este stack no hay retención: el bucket se crea sin DOCKER_INFLUXDB_INIT_RETENTION, ni en el compose del historian ni en el del bundle, así que queda infinita.

El riesgo real, entonces, no es la retención sino el disco de la Pi. Ver historian → InfluxDB.

El watermark no espera al Cloud

Al cerrar cada ciclo el watermark avanza al mayor timestamp de los eventos resueltos —ACKED o DEAD—, ignorando los PENDING. No espera que el lote entero haya llegado bien: los PENDING quedan en el outbox, que es su fuente de verdad para el reintento.

Eso desacopla la relectura de InfluxDB del estado del Cloud, y es lo que evita que una caída de la nube haga que el Edge relea las mismas ventanas para siempre.

Lo que ningún extremo implementa

El latido. El contrato define POST /api/v1/consumers/heartbeat y kind: "heartbeat", y las dos puntas lo tienen sin hacer: el handler del Cloud devuelve 501 —con un comentario que dice que está fuera de alcance porque el Edge no lo usa— y el método del Edge devuelve «no implementado».

Es coherente, no roto. Pero tiene una consecuencia que conviene nombrar: no hay ninguna señal periódica de que un Pi esté vivo. Lo más cercano es el last_seen_at que el Cloud refresca —best-effort, con su propio timeout de 2 s para que un Postgres degradado no le sume latencia a cada respuesta de ingesta— cuando una ingesta funciona. Un Pi que deja de enviar no avisa: se nota porque deja de haber datos.

Eso se cruza con dos huecos abiertos: nadie recolecta las métricas del Edge ni del Cloud, y un MACHINE_ID que no coincide descarta todos los eventos en silencio. Ver monitoreo.

Qué cubre el CI de esta frontera

Nada de punta a punta, y menos de lo que parece en cada extremo:

Repo Qué corre Qué no
Cloud go test ./... Los tests de integración se saltean solos sin MONGO_URI, y el CI no la provee. El índice único —toda la idempotencia— queda sin cubrir
Edge go test vía make test, go vet, go build para dos arquitecturas, imagen multi-arch y control de deriva del OpenAPI generado Ninguna integración contra un InfluxDB ni contra un Cloud real: los tests son unitarios

Los tests del Edge son los que fijan el formato del aasPath, la clasificación de códigos y el cálculo del eventId —justamente los invariantes de esta frontera—, y bloquean un merge.

Del lado del Cloud no: la idempotencia —el índice único— no la cubre ningún CI. Ver calidad y resiliencia.

Qué no puede decir esta página

  • Si esta frontera funcionó alguna vez contra el Cloud real en producción. Los dos extremos implementan el mismo contrato, pero el stack nunca corrió en una Raspberry Pi: que un lote haya viajado de una planta a la nube no consta en ningún lado.
  • Cuál es la latencia real de un lote. No hay medición.
  • Qué pasa con un 403 sostenido. El Edge reintenta indefinidamente asumiendo rotación de clave; con una clave revocada de verdad, el outbox se llena hasta saturar y el Edge deja de aceptar mediciones nuevas. La secuencia sale del código y nadie la ejecutó.
  • Si Redis está desplegado en producción, y por lo tanto si el límite de tasa está activo o abierto.

Dónde está implementado

Qué Dónde
Ruta, headers, resolución del tenant desde la clave y rechazo del machineId ajeno internal/consumers/middleware/middleware.go y internal/app/ingest/validate.go del Cloud
Significado de cada código y los tres invariantes internal/domain/ingest/measurement.go y internal/consumers/events_handler.go del Cloud
Clasificación del lado del Edge internal/app/forwarder/errors.go
Códigos HTTP que el Edge mapea a cada desenlace internal/platform/cloudclient/http_client.go
Backoff con max(exponencial, Retry-After), jitter ±20 %, sin tope de reintentos internal/platform/cloudclient/backoff.go
Idempotency-Key = sha256 de los eventId, y que el Cloud solo la loggea internal/repo/sqlite/outbox/repo.go del Edge y internal/consumers/events_handler.go del Cloud
Índice único (tenantId, eventId), índice sparse sobre payload.aasPath, ordered: false internal/repo/mongo/measurements/repository.go del Cloud
Límites y sus valores por defecto internal/config/config.go de los dos repos
Fallo abierto del límite de tasa sin Redis internal/consumers/ratelimit.go del Cloud
Saturación del outbox, TTL de DEAD y purga al confirmar EDGE-ADR-011 y internal/repo/sqlite/outbox/repo.go
/heartbeat sin implementar en los dos extremos internal/consumers/router.go del Cloud y internal/platform/cloudclient/http_client.go del Edge

El contrato del lado del Edge, mantenido por el repo que lo consume, está en docs/contracts/cloud-outbound.md.

Ese contrato no coincide con esta página en dos puntos, y manda lo que dice acá: /events está implementado —su sección de estado lo da por 501, que solo vale para /heartbeat— y el Cloud no da la garantía de lote del Idempotency-Key.

Relacionado