Saltar al contenido principal

ADR-005 Jobs como operaciones, política afuera y mecanismo adentro

  • Estado: accepted (2026-11-07)
  • Fecha: 2026-11-07
  • Issue: OCT-47. Habilita: OCT-18 (intent-expiration) y el primer batch (requiere OCT-48). Depende de ADR-001 (accepted).

Contexto​

Cada afirmación sobre código está verificada en octopus-api (rutas relativas a ese repo).

No hay trabajo programado en el kernel​

  • grep -rn @Scheduled sobre todos los .kt y .java no devuelve nada. Tampoco hay @EnableScheduling.
  • ControlCorruptedBalance es manual: corrige los contadores de una intención APPROVED trabada, a pedido, con dryRun opcional (modules/transactions/.../operations/ControlCorruptedBalance.kt). Su federation lo declara publicApi = false (.../federation/ControlCorruptedBalanceFederation.kt).
  • El único proceso periódico es OutboxPoller (platform/outbox/core/.../polling/OutboxPoller.kt): un bucle de corrutinas con delay(options.delayMs) que arranca OutboxDispatcherBootstrapper al ApplicationReadyEvent si octopus.outbox.dispatcher.enabled es true (apps/polling/src/main/kotlin/OutboxDispatcherBootstrapper.kt). Es un bucle fijo en código, no un job: no tiene schedule declarado, ni execution, ni lock de instancia única (la exclusión entre pollers la da FOR UPDATE SKIP LOCKED en OutboxPollingRepository, ver ADR-001).
  • El CLAUDE.md del workspace registra la consecuencia como brecha conocida: no hay expiración automática de intenciones, y los límites diarios se inflan si el outbox falla.

Cómo es hoy una intención que vence​

  • La entidad es TransactionsIntentEntity (modules/shared/.../transactions/entities/TransactionsIntentEntity.kt): tiene expires_at, intent_status y modifier.
  • No existe un estado HOLD. Los estados de intención son APPROVED, REJECTED, PENDING_AUTHORIZATION, FAILED y EXPIRED (opciones de intentStatus en ListTransactionsFederation.kt); «HOLD» es un valor de modifier (TransactionModifierTypes). Una intención de retención es entonces modifier = HOLD.
  • El vencimiento se evalúa hoy solo de forma perezosa, cuando alguien ejecuta: ExecuteTransactionHandler compara intent.expiresAt < Instant.now(), marca la intención EXPIRED y llama compositeTransactionService.revertPending (modules/transactions/.../handlers/ExecuteTransactionHandler.kt). Si nadie ejecuta, la intención nunca vence. ControlCorruptedBalanceHandler también puede marcarla EXPIRED, a mano.

Qué lecturas sobre intenciones existen​

OperaciónpublicApiQué permiteQué no permite
ListTransactionssí (READONLY)filtrar por holder, cuenta, intentStatus, transactionStatus, perfil, rango de createdAt; paginadofiltrar por modifier; filtrar por vencimiento
RetrieveTransactionsí (READONLY)leer una transacción por idlistar

ListTransactions lee TransactionsView, que no expone expires_at (modules/shared/.../transactions/views/TransactionsView.kt). Para intent-expiration faltan, y hay que crear, una lectura equivalente a ListIntents(modifier = HOLD, intentStatus = APPROVED, expiresBefore = now) con paginación estable por expiresAt, y la columna de vencimiento en la vista. Hoy el único camino para listar vencidos sería un repositorio del módulo transactions, justo lo que esta ADR prohíbe a un job.

Lo que el kernel ya ofrece para el otro extremo​

  • Un evento puede disparar una operación: la federation declara effect<Evento, Operación> y FederationEffectRegistry admite un solo efecto por tipo de evento (platform/operations/effects/.../FederationEffectRegistry.kt). TransactionIntentApproved → ExecuteTransaction es el caso canónico (ExecuteTransactionFederation.kt).
  • El efecto invoca la operación con un InvocationContext cuya idempotencyKey es el eventId (platform/operations/effects/.../EffectEngineImpl.kt, buildContextFromMetadata). Las operaciones con IdempotencyMode.REQUIRED, como InitiateTransaction, devuelven el resultado anterior ante la misma clave.
  • La verificación de partida doble existe como operación pública de lectura, RunDoubleEntryCheck(businessDate?, unitCode?) (modules/audit/.../operations/RunDoubleEntryCheck.kt, AuditFederation.kt). Se parametriza por fecha y unidad, no por lote.

Principios que esta ADR respeta​

  • El kernel es la única puerta a la base de datos (garantías).
  • El kernel modela garantías, no productos: la política de negocio (qué retener, a quién, cuándo) vive fuera del núcleo (transactions, balances, ledger), en capas operadoras.
  • Las brechas identificadas se cierran, no se difieren (heurística del proyecto «2 · Corrección del kernel»).

Decisión​

Job = política, kernel = mecanismo. El job decide qué hay que hacer; el núcleo ejecuta y garantiza que cierra.

Un job es una operación​

  • Se registra en una federation como cualquier otra (manifest, inputs tenant y window, publicApi). Invocarla pasa por el ExecutionEngine completo: execution persistida, outbox, correlación y estado final. «Ejecutar ahora» es invocar la operación.
  • El recurso kind: Job declara solo qué operación correr y cuándo (schedule, tenantScope, enabled), visible en la consola y en el CLI (octopus job list, octopus job run-now).
  • La lógica vive en el job: en la capa services/ del repo (p. ej. intent-expiration) o en un servicio operador aparte sobre octopus-sdk (p. ej. retenciones en octopus-accounting). El framework no distingue.

Un runtime de jobs los programa​

  • apps/jobs en modo distribuido, corrutinas en el monolito. Hospeda jobs como plugins y entrega a cada uno: cliente del kernel, emisor de eventos, lock de instancia única y métricas.
  • En el monolito la invocación es en proceso; en distribuido, apps/jobs corre el mismo código con cliente gRPC.
  • «API pública» en esta ADR significa operaciones y puertos de platform/, no red.

Regla dura: el job lee, decide y emite​

  1. Lee solo por operaciones o puertos de platform/, nunca por repositorios de otro módulo (misma regla que OCT-33 impondrá con ArchUnit; hoy no hay ArchUnit en el repo). Si la lectura no existe, se crea: enriquece la API para consola y MCP.
  2. No llama InitiateTransaction ni ninguna operación de escritura del núcleo. Emite eventos de tarea con el objeto ya resuelto: ReleaseIntentRequested(intentId), TaxWithholdingRequested(account, amount, batchId), BatchCloseRequested(batchId). Salen por el outbox de la execution del job (EmitPrimaryEventStep), atómicos con ella, y viajan por el puerto de eventos de ADR-001, con lanes ordenadas por cuenta.
  3. Quien ejecuta es un effect handler del kernel: consume el evento, revalida, aplica idempotencia e inicia la transacción por InitiateTransaction con su propia execution. Son dos ejecuciones encadenadas por un evento.
  4. El kernel revalida al ejecutar. Si el estado cambió entre la lectura del job y la ejecución (el hold ya se capturó), la operación falla limpio y queda registrado en la execution. El job propone, el kernel dispone.

Idempotencia por evento​

La clave lógica es (job, item, window). El efecto usa el eventId como idempotencyKey (ver Contexto), así que el emisor deriva el eventId del evento de tarea de forma determinista a partir de esa clave: una reemisión tras un reinicio produce la misma clave y el kernel devuelve el resultado anterior en lugar de repetir el efecto. Esto exige que el emisor fije el id del EventEnvelope (ver respuesta 6; ADR-001 lo recoge).

Dos formas de job​

  • Periódico: lee, decide, emite un evento por ítem. Ejemplo: intent-expiration.
  • Batch sobre una población: el job crea un batch_id, enumera la población por la API pública, emite un evento por ítem con (batch_id, item) como clave y, al final, BatchCloseRequested(batch_id). Al cerrar, el kernel verifica que la suma de los ítems sea igual al total y que la partida doble dé cero, en una execution auditable. Escribe en el purpose del batch (p. ej. PROFIT), no en PRIMARY, para no competir con el tráfico en vivo. Es reanudable: el job reemite lo pendiente y el kernel descarta lo ya hecho.

Por qué eventos y no gRPC síncrono desde el job​

El job no se bloquea en N llamadas; el kernel procesa a su ritmo y con orden por cuenta; y si el kernel está caído, los eventos esperan en el outbox. Para un batch de miles de ítems es la diferencia entre un proceso que se cae a mitad de camino y uno que se reanuda.

Anti-objetivo​

No es un orquestador: sin DAGs ni dependencias entre jobs. Si un caso las necesita, se resuelve con eventos entre jobs o se reabre esta ADR.

Alternativas consideradas​

  • @Scheduled dentro del kernel. Es lo más corto, pero mete reloj y política en el núcleo de garantías, no deja execution ni schedule declarado, y duplica la acción en cada instancia sin lock. Descartada.
  • Un job que lee la base y escribe balances directo. Rompe la única puerta a la base de datos y saltea la partida doble, la idempotencia y el audit trail. Descartada sin discusión.
  • Un daemon que solo emite un tick, con la lógica en handlers del kernel (JobTickRequested, el handler busca los vencidos y libera). Es el diseño que describe hoy OCT-18. Se descarta porque pone política de negocio —qué vence, a quién retener— dentro del núcleo, contra el principio de capas operadoras; el kernel pasaría a conocer cada producto que tenga un job.
  • Un scheduler externo (EventBridge, CronJob) que llama gRPC de forma síncrona por ítem. Resuelve el reloj pero no la contención: N llamadas bloqueantes, sin orden por cuenta, sin tolerancia a un kernel caído y con la idempotencia repartida entre clientes. El scheduler sí es reemplazable: ver respuesta 7.
  • Un orquestador de workflows genérico (estilo Temporal). Aporta DAGs, reintentos y estado durable, que están fuera del alcance (anti-objetivo), y suma un sistema más a operar para dos jobs.

Consecuencias​

Positivas: el kernel sigue siendo agnóstico de producto; un job se prueba como política pura contra un cliente de kernel falso; apps/polling se reduce al job outbox-sweeper; toda acción periódica es una execution auditable; el mismo runtime hospeda los futuros jobs de contabilidad y cumplimiento.

Negativas:

  • La API pública de lectura tiene que crecer. Lista mínima para los dos primeros jobs: ListIntents (o ListTransactions con filtros modifier y expiresBefore y la columna expires_at en la vista) para intent-expiration; para outbox-sweeper, un puerto de administración de particiones del outbox (hoy solo existen claimBatch, markProcessed, scheduleRetry, markFailed y los inserts en OutboxRecordStore).
  • Un runtime más que operar en modo distribuido.
  • N eventos por batch son carga de outbox: 50 000 ítems son 50 000 filas más las de sus efectos. La partición por día y la limpieza son las de ADR-001 (respuesta 5, a cargo del propio outbox-sweeper).
  • El cierre de batch necesita una operación de verificación en el kernel que no existe: VerifyBatch(batchId) (respuesta 4). RunDoubleEntryCheck se parametriza por fecha y unidad, no por batch_id.
  • Un batch que calcula importes con porcentajes hereda el truncamiento a Long de ExpressionEvaluator hasta que se resuelva OCT-48.

Plan​

PasoContenidoPrueba de aceptación
ADR-001 acceptedYa cumplido (2026-11-07)—
Jobs runtime (OCT-53)Hospedaje de plugins, schedule, lock de instancia única, cliente del kernel, emisor de eventos, métricasUn job de prueba corre en el monolito y en apps/jobs; una segunda instancia no lo duplica
Lecturas públicasListIntents o filtros nuevos en ListTransactions; expires_at en la vistaUn hold vencido aparece; uno capturado no
OCT-18intent-expiration como primer job y effect handler de ReleaseIntentRequestedHold vencido liberado; un hold capturado en carrera falla limpio; sum(ledger) intacto. El texto de OCT-18 ya está reescrito con este diseño
OCT-48Redondeo explícito en el profilePrerrequisito de cualquier batch
BatchEvento de cierre y operación VerifyBatch(batchId) del kernelSuma de ítems igual al total y partida doble en cero

Preguntas resueltas​

Respuestas de Delmo del 2026-11-07; con ellas el ADR pasa a accepted.

  1. Lock de instancia única. pg_try_advisory_lock(hash(jobName)) en una conexión dedicada del runtime: si la conexión cae, el lock se libera. Leases solo si algún día hay más de dos instancias.
  2. outbox-sweeper. Es un job del runtime, con una excepción documentada: opera sobre el outbox por un puerto de platform/outbox (infraestructura del framework, no módulo de dominio), porque no puede depender del transporte que limpia. Emite un evento solo para auditoría.
  3. Retención de los eventos de tarea. La misma política que todos los eventos (ADR-001, respuesta 5).
  4. Cierre de batch. Operación del kernel VerifyBatch(batchId): la garantía es del kernel. Un job de auditoría puede invocarla.
  5. Nombre de la familia de eventos de tarea. *Requested.
  6. EventEnvelope.id. Lo fija el emisor. Por defecto, un UUIDv7 que genera el kernel. En un evento de tarea, UUIDv5(namespace, "job:item:window"), determinista. La reemisión se rechaza al insertar en el outbox por el índice único sobre event_id, y idempotencyKey = eventId en el efecto cubre el resto. Se agregó la línea correspondiente a ADR-001, sección «Eventos y puertos».
    • El índice único de event_id es por partición (idx_outbox_<n>_event_id, octopus-local/db/changelog/structure/003-events.sql), no una restricción de tabla; con la partición por día de ADR-001 (respuesta 5) solo rechaza la reemisión dentro de la misma partición. Una reemisión en otro día la ataja el idempotencyKey del efecto.
    • Los ids de evento de hoy son TypeID con prefijo ev (EventId, platform/api/.../TypedIds.kt) y la columna es VARCHAR(32): un UUID con guiones (36 caracteres) no entra. El UUIDv5 se codifica como el resto de los ids; falta confirmar al implementar que EventId.of acepta un UUID que no sea v7.
  7. Scheduler externo. Soportado como configuración schedule: external en el recurso Job: el runtime no dispara y solo expone «ejecutar ahora».

Decisiones para OCT-18​

Solo el caso (b), la liberación de un HOLD ya ejecutado. El caso (a), una intención APPROVED sin ejecución, es recuperación de ejecuciones trabadas y va a OCT-54, dominio del outbox-sweeper.

Siguen abiertas​

Ninguna.