ADR-001 Transporte de eventos detrás de puertos
- Estado: accepted (2026-11-07)
- Issue: OCT-12. Implementación: OCT-21 → OCT-22 → OCT-23 → OCT-24.
Contexto
El flujo de dos fases del kernel (intención, luego ejecución disparada por un evento) depende hoy de Kafka y de Schema Registry, incluso con una sola instancia del kernel. Esta sección describe lo que el código hace hoy; cada afirmación está verificada en octopus-api (rutas relativas a ese repo).
Cómo fluyen los eventos hoy
EmitPrimaryEventStep(platform/operations/execution/.../steps/) construye unStagedEventa partir del resultado del handler;PersistOutboxSteplo guarda como fila del outbox víaEventStoreImpl.persistAll(modules/operations/.../events/EventStoreImpl.kt).OutboxPoller(platform/outbox/core/.../polling/) reclama un lote conUPDATE ... WHERE event_pk IN (SELECT ... ORDER BY event_pk LIMIT :batchSize FOR UPDATE SKIP LOCKED)(modules/events/.../OutboxPollingRepository.kt), yOutboxEntryProcessordecodifica el payload y llamaOutboxRelay.send(topic, message). La implementación esKafkaOutboxRelay, y la clave del registro Kafka es laserializationKey(sdk/messaging/.../producer/MessageKafkaProducer.kt).- En el otro extremo,
KafkaEffectSubscriptionManager(modules/operations/.../effects/) suscribe un consumidor Kafka (grupooctopus-effects, commit manual) que invocaEffectEngine.process. El consumidor procesa los registros secuencialmente en un único bucle depoll(sdk/messaging/.../consumer/MessageKafkaConsumer.kt).
Qué hace la configuración en cada app
| App | octopus.outbox.dispatcher.enabled | octopus.operations.effects.enabled |
|---|---|---|
api | false | true (crea el suscriptor Kafka) |
effects | false | true |
polling | true (16 particiones de outbox) | false |
Es decir: api no publica en Kafka; escribe filas de outbox. Solo polling publica. La contradicción del inventario venía de que api configura bootstrapServers para el dispatcher aunque lo tenga apagado, y de que KAFKA_BOOTSTRAP_SERVERS no tiene valor por defecto en apps/api (sí en effects y polling), así que api no arranca sin Kafka.
Serialización
OutboxCodecRegistry resuelve un OutboxCodec por contentType: AvroOutboxCodec (sobre ConfluentAvroCodec, formato con byte mágico y Schema Registry) y JsonOutboxCodec. Los eventos Avro son SpecificRecord generados desde .avsc (typealias MessagePayload = SpecificRecord). Hay 74 archivos .avsc: 39 records y 35 enums; los eventos declarados en las federations son 25 (EventType.of). La fuente de verdad del modelo es el .avsc, es decir, la infraestructura define el modelo.
Por qué Kafka, y qué garantiza
Kafka se eligió para serializar los eventos de cuentas calientes por serializationKey usada como clave de partición; los shards existen por la misma razón del lado de escritura. Hoy garantiza orden por clave dentro de una partición. No garantiza: el orden entre las dos patas de un asiento doble, ni los contadores diarios y mensuales, que se actualizan de forma síncrona en la fase de intención (BalanceCounterRepository.incrementPending desde InitiateTransaction, modules/transactions/.../repositories/), fuera de la serialización por clave.
Dos desvíos respecto del diseño pretendido
Verificados al escribir este ADR; condicionan la decisión:
- Los eventos primarios se guardan después del commit.
EventTraitsSpec.phasevaleAFTER_COMMITpor defecto (platform/operations/api/.../OperationApi.kt) y ninguna federation declaraBEFORE_COMMIT.PersistOutboxStep(BEFORE_COMMIT)corre dentro del bloque transaccional de escritura, peropersistOutboxAftercorre fuera (DefaultExecutionPlanBuilder.forRoot). Hoy, entonces, el evento que disparaExecuteTransaction(TransactionIntentApproved) no es atómico con la intención: un fallo entre el commit y la escritura del outbox deja una intención aprobada sin evento. - La clave de serialización puede quedar nula en silencio. Si
EmitPrimaryEventStepno puede extraer laserializationKeyRef, registra unwarny sigue con clave nula (EmitPrimaryEventStep.kt);MessageKafkaProducerenvía entonces sin clave y se pierde el orden por clave. Las rutas del bus de runtime sí fallan (ExecutionRuntimeBusImpl.buildStaged).
El dolor
Kafka y Schema Registry están en el camino de arranque de api (requireNotNull(props.effects.bootstrapServers) en OperationsRuntimeAutoConfiguration); cada ejecución de la suite de integración levanta Kafka y Schema Registry en Testcontainers (modules/tests/.../SharedTestsConfiguration.kt); el stack local necesita Kafka (Redpanda con registry), PostgreSQL, Keycloak y los servicios del kernel; y Avro hace que la infraestructura defina el modelo de eventos.
Decisión
Eventos y puertos
- Los eventos son data classes de Kotlin en
platform/events/api(hoy ese módulo no existe; lo más cercano esplatform/operations/events). Única dependencia tolerada:kotlinx-serialization-core, documentada como excepción a la regla de cero dependencias de los módulos-api. EventEnvelope<T>:id,type,tenant,correlation,occurredAt,partitionKey,payload.- El
iddelEventEnvelopelo fija el emisor. Por defecto es un UUIDv7 generado por el kernel; los eventos de tarea de los jobs usan un UUIDv5 determinista derivado dejob:item:window, para que reemitirlos sea idempotente (ADR-005, respuesta 6). - Puertos:
EventCodec(encode/decode ycontentType),EventTransport.dispatch(envelope)yEventSubscription.subscribe(types, handler). El orden porpartitionKeyes una propiedad del contrato del puerto, no del adaptador.
Adaptadores
inprocess: la tabla del outbox como cola,FOR UPDATE SKIP LOCKED, N laneshash(partitionKey) % Nen la misma JVM (16 por defecto, configurable), cada una una corrutina secuencial. Opcional:pg_advisory_xact_lock(hash(key))como respaldo en la ejecución.kafka:partitionKey→ partición. Avro derivado de la data class (avro4k o equivalente; hay que verificar compatibilidad con Kotlin 2.2), con el.avsccomo artefacto generado y publicado, nunca editado a mano. El registry es opcional.sqs-fifo: diseñado, no implementado (MessageGroupId = partitionKey,MessageDeduplicationId = id; restricciones en OCT-45).- Codecs:
jsonpor defecto;avrosolo para el adaptadorkafkacuando hay registry configurado.
Runtime
El perfil monolith corre api + polling + effects en una JVM con inprocess; el perfil distributed mantiene apps separadas con kafka. En ambos, OutboxPoller pasa a ser un barrido de recuperación ante caídas, de baja frecuencia. Los efectos de los conectores de payments usan el mismo transporte, con una suscripción separada y su propio pool de lanes.
Lo que no cambia
- Las dos fases: la intención se persiste y la ejecución ocurre por un evento.
- El outbox como garantía: la fila se escribe en la misma transacción que el handler, con la semántica de
BEFORE_COMMIT/AFTER_COMMIT. Esta invariante hoy no se cumple para los eventos primarios (ver arriba); se corrige primero (OCT-49, antes de OCT-21) y la suite de contrato de OCT-21 incluye un test que la verifica (ver preguntas resueltas). - La resolución de la
serializationKeyfalla cerrada: es la intención declarada y el segundo desvío la contradice hoy en el camino de eventos primarios; OCT-50 lo corrige. - Se rechaza explícitamente el atajo "sacar Kafka = ejecutar
Executedentro deInitiate": un fallo entre las dos fases dejaría la intención sin resolver, y una ejecución rechazada abortaría la transacción que tiene que persistir la intención rechazada.
Cuándo Kafka es obligatorio
Cuando más de una instancia del kernel necesita orden por clave entre procesos, o cuando consumidores externos necesitan replay. No por TPS.
Alternativas consideradas
- Mantener Kafka obligatorio. Preserva todo lo que hay, pero deja el costo de arranque, de tests y de despliegue, y mantiene a Avro como definición del modelo.
- Postgres
LISTEN/NOTIFY. No es durable ni entrega en orden por clave; sirve como señal de despertar del poller, no como transporte. - Redis streams. Resuelve el orden por clave con un componente más a operar. La decisión previa sobre Redis no está escrita en ningún repositorio (solo existe
octopus-infra/k8s/infra/redis); se documentará en la página "Decisiones previas a las ADRs" (issue aparte), donde se enlazará. - Debezium CDC como único relay. Mantiene a Kafka como requisito y agrega Connect. Existe
docker/debezium.ymlenoctopus-local, pero el kernel no depende de él. - JSON en todas partes, incluido Kafka. Más simple, pero pierde las verificaciones de compatibilidad del registry sin reemplazarlas por nada; por eso Avro queda opcional en el adaptador
kafka. - Mantener Avro como fuente de verdad y generar las data classes desde
.avsc. Deja la definición del modelo en archivos de infraestructura y obliga a regenerar para cambiar un evento.
Consecuencias
Positivas: local = PostgreSQL + Keycloak; tests sin Kafka ni registry; un solo contenedor; VPS o AWS sin MSK; los eventos quedan tipados en Kotlin.
Negativas:
- Dos transportes que mantener equivalentes.
- La cantidad de lanes es una perilla de ajuste.
- La tabla del outbox, que hoy se particiona por lista (
PARTITION BY LIST (partition)), necesita política de limpieza en modo monolito: partición por día y un job de barrido (ver preguntas resueltas). - Se pierde la verificación de compatibilidad del registry; se reemplaza por tests de contrato sobre las data classes.
- Los contadores diarios y mensuales de
debit_pending_rawse actualizan en la fase de intención, fuera de la serialización por clave; hay que medirlo en el load test (OCT-24).
Plan de migración
Orden: OCT-49 y OCT-50 (correcciones del kernel actual) se resuelven antes de OCT-21. Regla transversal: ambos adaptadores pasan la misma suite de orden por clave e idempotencia.
| Paso | Contenido | Prueba de aceptación |
|---|---|---|
| OCT-21 | Puertos EventTransport/EventCodec y adaptador inprocess | Suite de contrato: orden por clave con N lanes; sin pérdida ni duplicados tras reinicio; eventos primarios atómicos con la intención |
| OCT-22 | Los eventos Avro pasan a data classes detrás del alias | Todos los eventos declarados (25) round-trip por json; tests de contrato sobre las data classes |
| OCT-23 | Adaptador kafka sobre los puertos; registry fuera de local y tests | La misma suite de contrato pasa contra Kafka; la suite de integración corre sin Kafka |
| OCT-24 | Load test del camino HOLD en modo monolito | Percentiles y contención de contadores medidos; resultado reportado |
Preguntas abiertas
Respuestas de Delmo del 2026-11-07; con ellas el ADR pasa a accepted.
Resueltas
- Atomicidad del evento primario.
BEFORE_COMMITpor defecto;AFTER_COMMITsolo de forma explícita y justificada, para eventos que no disparan ejecución. Las dos federations que hoy fijan la fase se revisan en OCT-49. Se arregla ahora, antes de OCT-21. - Clave nula.
EmitPrimaryEventStepfalla cerrado conSERIAL_KEY_RESOLUTION_FAILEDdesde OCT-50; mismo criterio paraentityIdRef(ENTITY_ID_RESOLUTION_FAILED). El bus de runtime (ExecutionRuntimeBusImpl, eventos emitidos conemit(payload)) todavía no valida la clave: queda pendiente de alinearse en OCT-52. - Lanes por defecto. 16 (las particiones del outbox que ya usa
polling), configurable. - Efectos de
payments. Mismo transporte, suscripción separada con su propio pool de lanes: un conector lento no bloquea la ejecución del kernel. - Retención del outbox. Partición por día. El job
outbox-sweeper(ADR-005, todavía no escrito; OCT-47) haceALTER TABLE outbox DETACH PARTITION ... CONCURRENTLY(sentencia suelta, autocommit) y despuésDROP TABLEde la partición desprendida, nuncaDROP PARTITIONdirecto, que bloquea al padre. Solo se desprende una partición con todas sus filasPROCESSED(índice parcial sobrestatus <> 'PROCESSED'); al arrancar, el job limpia las particiones huérfanas de un detach interrumpido. La auditoría vive enexecutions.
Siguen abiertas
- Contadores diarios. Se mide en OCT-24; sin decisión hasta tener el número.
- Referencia a Redis. No está escrita en ningún repositorio; va a una página "Decisiones previas a las ADRs" en
octopus-docs(issue aparte).