Skip to main content

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​

  1. EmitPrimaryEventStep (platform/operations/execution/.../steps/) construye un StagedEvent a partir del resultado del handler; PersistOutboxStep lo guarda como fila del outbox vía EventStoreImpl.persistAll (modules/operations/.../events/EventStoreImpl.kt).
  2. OutboxPoller (platform/outbox/core/.../polling/) reclama un lote con UPDATE ... WHERE event_pk IN (SELECT ... ORDER BY event_pk LIMIT :batchSize FOR UPDATE SKIP LOCKED) (modules/events/.../OutboxPollingRepository.kt), y OutboxEntryProcessor decodifica el payload y llama OutboxRelay.send(topic, message). La implementación es KafkaOutboxRelay, y la clave del registro Kafka es la serializationKey (sdk/messaging/.../producer/MessageKafkaProducer.kt).
  3. En el otro extremo, KafkaEffectSubscriptionManager (modules/operations/.../effects/) suscribe un consumidor Kafka (grupo octopus-effects, commit manual) que invoca EffectEngine.process. El consumidor procesa los registros secuencialmente en un único bucle de poll (sdk/messaging/.../consumer/MessageKafkaConsumer.kt).

Qué hace la configuración en cada app​

Appoctopus.outbox.dispatcher.enabledoctopus.operations.effects.enabled
apifalsetrue (crea el suscriptor Kafka)
effectsfalsetrue
pollingtrue (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.phase vale AFTER_COMMIT por defecto (platform/operations/api/.../OperationApi.kt) y ninguna federation declara BEFORE_COMMIT. PersistOutboxStep(BEFORE_COMMIT) corre dentro del bloque transaccional de escritura, pero persistOutboxAfter corre fuera (DefaultExecutionPlanBuilder.forRoot). Hoy, entonces, el evento que dispara ExecuteTransaction (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 EmitPrimaryEventStep no puede extraer la serializationKeyRef, registra un warn y sigue con clave nula (EmitPrimaryEventStep.kt); MessageKafkaProducer enví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 es platform/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 id del EventEnvelope lo fija el emisor. Por defecto es un UUIDv7 generado por el kernel; los eventos de tarea de los jobs usan un UUIDv5 determinista derivado de job:item:window, para que reemitirlos sea idempotente (ADR-005, respuesta 6).
  • Puertos: EventCodec (encode/decode y contentType), EventTransport.dispatch(envelope) y EventSubscription.subscribe(types, handler). El orden por partitionKey es una propiedad del contrato del puerto, no del adaptador.

Adaptadores​

  • inprocess: la tabla del outbox como cola, FOR UPDATE SKIP LOCKED, N lanes hash(partitionKey) % N en 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 .avsc como 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: json por defecto; avro solo para el adaptador kafka cuando 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 serializationKey falla 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 Execute dentro de Initiate": 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.yml en octopus-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_raw se 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.

PasoContenidoPrueba de aceptación
OCT-21Puertos EventTransport/EventCodec y adaptador inprocessSuite de contrato: orden por clave con N lanes; sin pérdida ni duplicados tras reinicio; eventos primarios atómicos con la intención
OCT-22Los eventos Avro pasan a data classes detrás del aliasTodos los eventos declarados (25) round-trip por json; tests de contrato sobre las data classes
OCT-23Adaptador kafka sobre los puertos; registry fuera de local y testsLa misma suite de contrato pasa contra Kafka; la suite de integración corre sin Kafka
OCT-24Load test del camino HOLD en modo monolitoPercentiles 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​

  1. Atomicidad del evento primario. BEFORE_COMMIT por defecto; AFTER_COMMIT solo 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.
  2. Clave nula. EmitPrimaryEventStep falla cerrado con SERIAL_KEY_RESOLUTION_FAILED desde OCT-50; mismo criterio para entityIdRef (ENTITY_ID_RESOLUTION_FAILED). El bus de runtime (ExecutionRuntimeBusImpl, eventos emitidos con emit(payload)) todavía no valida la clave: queda pendiente de alinearse en OCT-52.
  3. Lanes por defecto. 16 (las particiones del outbox que ya usa polling), configurable.
  4. Efectos de payments. Mismo transporte, suscripción separada con su propio pool de lanes: un conector lento no bloquea la ejecución del kernel.
  5. Retención del outbox. Partición por día. El job outbox-sweeper (ADR-005, todavía no escrito; OCT-47) hace ALTER TABLE outbox DETACH PARTITION ... CONCURRENTLY (sentencia suelta, autocommit) y después DROP TABLE de la partición desprendida, nunca DROP PARTITION directo, que bloquea al padre. Solo se desprende una partición con todas sus filas PROCESSED (índice parcial sobre status <> 'PROCESSED'); al arrancar, el job limpia las particiones huérfanas de un detach interrumpido. La auditoría vive en executions.

Siguen abiertas​

  1. Contadores diarios. Se mide en OCT-24; sin decisión hasta tener el número.
  2. 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).