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:
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ás400. Un400convertirí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. El400queda reservado a requests rotos a nivel de sobre: cuerpo ilegible, JSON malformado,eventsvací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 comoDEAD: 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), noeventIdsolo. EleventIdno lleva el tenant adentro, y elmachineIdsolo es único por tenant, así que dos clientes distintos pueden generar el mismoeventIdde forma legítima. Lo fijaSYS-ADR-002. - La inserción es
ordered: false, y eso no es una optimización: conordered: trueel 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á¶
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
403sostenido. 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¶
- Historian → InfluxDB — el tramo anterior
- Frontend → Cloud — qué hace el Cloud con lo que recibe acá
- Catálogo de variables — el
aasPathque viaja en elpayload SYS-ADR-002— protocolo, idempotencia y clave por dispositivoSYS-ADR-009— la forma canónica delaasPath- Flujo de datos — las garantías vistas de punta a punta
- Alta de un dispositivo Edge — cómo se emite la clave que autentica esta frontera