Saltar a contenido

Compensación y BPMN error handling

Semántica de excepción y reversión en BPMN. Errors (boundary events) son la primaria; compensation es el "undo" coordinado para sagas. Implementación M2.

Modelo mental: 3 mecanismos distintos

BPMN tiene tres formas de manejar lo que en código sería un try/catch/finally:

  1. BPMN errors: error semántico de negocio (insufficient funds, customer not found). Sale por un boundary error event. Interrupting la actividad.
  2. Incidents: error técnico inesperado (DB caída, worker bug). Requiere intervención humana / operacional.
  3. Compensation: rollback coordinado de pasos previos. Trigger explícito vía compensation throw event.

Importante: BPMN no tiene "try/catch genérico". Cada error tiene un code declarado; el modelo lo enruta.

BPMN Error Boundary Event

Diagrama (mental)

flowchart LR
    A[Service Task:<br/>charge-payment] --> B[next step]
    A -.->|boundary error<br/>code='insufficient-funds'| C[User Task:<br/>ask-different-payment]

Semántica de ejecución

  1. Worker ejecuta el job de charge-payment.
  2. Worker llama failJobWithBPMNError("insufficient-funds", "balance < amount").
  3. Engine recibe el comando, busca boundary error event con code="insufficient-funds" en el scope de la activity.
  4. Si encuentra match:
  5. Interrumpe la activity (clean-up).
  6. Activa el flow que sale del boundary event.
  7. Si NO encuentra match:
  8. Sube el error al scope padre (subprocess → process).
  9. Si llega al root sin match → incident (uncaught BPMN error).

Implementación (Postgres state)

-- Tabla de subscriptions por scope (resolution rápida)
CREATE TABLE error_event_subscriptions (
    process_instance_key BIGINT,
    element_instance_key BIGINT,  -- la activity con el boundary
    scope_key BIGINT,             -- scope donde aplica
    error_code TEXT,
    boundary_element_id TEXT,
    PRIMARY KEY (process_instance_key, element_instance_key, error_code)
);

-- Lookup en activación
SELECT boundary_element_id
FROM error_event_subscriptions
WHERE process_instance_key = $1
  AND error_code = $2
  AND scope_key IN (
      WITH RECURSIVE parents AS (
          SELECT key, parent_key FROM element_instances WHERE key = $3
          UNION ALL
          SELECT ei.key, ei.parent_key
          FROM element_instances ei
          JOIN parents p ON ei.key = p.parent_key
      )
      SELECT key FROM parents
  )
ORDER BY array_length(string_to_array(scope_key::text, ','), 1) DESC  -- innermost first
LIMIT 1;

Reglas de bubbling

  • Error sin handler en scope actual → bubble up.
  • Error sin handler en process raíz → incident.
  • Error en event subprocess (start con error) → handler activated, original flow termina.
  • Error en multi-instance → cada instancia se evalúa independientemente.

Variables al error

Al disparar el boundary event: - Si el worker pasó variables al fail, se mergean al scope. - Si el boundary tiene outputMapping, se aplica. - El error code y message quedan disponibles como variables: errorCode, errorMessage.

Incidents

Cuándo se crea un incident

  1. Uncaught BPMN error: error sin boundary handler en ningún scope.
  2. Job retry exhausted: retries: 0 en el job (típicamente N intentos fallaron).
  3. Expression evaluation error: FEEL/CEL falló y no hay fallback.
  4. Variable not found: referencia a variable inexistente.
  5. External system unreachable (conector): después de retry interno.

Lifecycle

stateDiagram-v2
    [*] --> CREATED
    CREATED --> RESOLVED: resolve<br/>(--update-retries=N)
    RESOLVED --> CompletedOrNewIncident: resume
    CREATED --> CANCELLED: cancel-instance
    CompletedOrNewIncident: completed or new incident

Esquema:

CREATE TABLE incidents (
    key BIGINT PRIMARY KEY,
    process_instance_key BIGINT NOT NULL,
    element_instance_key BIGINT,
    job_key BIGINT,
    error_type TEXT NOT NULL,  -- JOB_NO_RETRIES, EXTRACT_VALUE_ERROR, etc.
    error_message TEXT NOT NULL,
    state TEXT NOT NULL,  -- CREATED, RESOLVED
    tenant_id TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON incidents (state, tenant_id);
CREATE INDEX ON incidents (process_instance_key);

Resolución típica (Operate UI / wf CLI)

# Diagnosticar
wf incident get 22518...
wf instance timeline <instance-key>

# Reparar la causa
wf variable set <instance-key> amount=100.00  # corregir variable

# Reintentar
wf incident resolve 22518... --update-retries 3

Compensation

Caso de uso: saga distribuida

Imaginar un proceso de booking de viaje:

flowchart LR
    A[Book flight] --> B[Book hotel] --> C[Book car] --> D[Confirm trip]

Si "Book car" falla y queremos cancelar flight + hotel: compensation.

flowchart TD
    BF[Book flight] -.->|comp| CF[Cancel flight]
    BH[Book hotel] -.->|comp| CH[Cancel hotel]
    BC["Book car (failed)"]:::failed
    classDef failed fill:#f99,stroke:#900

Elementos del modelo

  1. Compensation boundary event atado a una activity: define qué hacer para "deshacer" esa activity.
  2. Compensation activity (service task con isForCompensation=true): el código de la compensación.
  3. Compensation throw event: trigger que dispara la cadena de compensaciones.
<bpmn:serviceTask id="bookFlight" name="Book flight">
  <bpmn:extensionElements>
    <zeebe:taskDefinition type="book-flight"/>
  </bpmn:extensionElements>
</bpmn:serviceTask>

<bpmn:boundaryEvent id="compBoundary1" attachedToRef="bookFlight">
  <bpmn:compensateEventDefinition/>
</bpmn:boundaryEvent>

<bpmn:serviceTask id="cancelFlight" isForCompensation="true">
  <bpmn:extensionElements>
    <zeebe:taskDefinition type="cancel-flight"/>
  </bpmn:extensionElements>
</bpmn:serviceTask>

<bpmn:association associationDirection="One"
                  sourceRef="compBoundary1"
                  targetRef="cancelFlight"/>

<bpmn:intermediateThrowEvent id="triggerComp">
  <bpmn:compensateEventDefinition activityRef=""/>  <!-- empty = compensate all -->
</bpmn:intermediateThrowEvent>

Semántica de ejecución

  1. Cuando una activity termina exitosamente, registra un subscription de compensation con su boundary handler.
CREATE TABLE compensation_subscriptions (
    process_instance_key BIGINT,
    element_instance_key BIGINT,
    completed_at TIMESTAMPTZ NOT NULL,
    scope_key BIGINT NOT NULL,
    handler_element_id TEXT NOT NULL,  -- el "cancelFlight"
    handler_variables JSONB,  -- snapshot de variables al momento del complete
    PRIMARY KEY (process_instance_key, element_instance_key)
);
  1. Cuando se dispara compensation throw:
  2. Si tiene activityRef: compensa solo esa activity.
  3. Si está vacío: compensa todo el scope actual en orden inverso al completion.
-- Lookup: qué compensar
SELECT * FROM compensation_subscriptions
WHERE process_instance_key = $1
  AND scope_key = $2
ORDER BY completed_at DESC;  -- LIFO
  1. Ejecución de cada compensation handler:
  2. Es un service task normal (worker pull).
  3. Recibe las variables del snapshot al momento del completion.
  4. Si la compensation falla: ese path queda como incident (no se ejecutan los siguientes a menos que se resuelva).

Reglas clave (frecuente fuente de bugs)

  • Solo activities completadas son candidatas a compensar. Una activity activa al momento del throw NO se compensa.
  • Variables snapshot: la compensation ve las variables que tenía al completar, no las actuales.
  • No re-entrante: una activity sólo se compensa una vez por throw event.
  • Subprocess compensation: throwing en un subprocess sólo compensa elementos dentro de ese subprocess. Para process-wide, usar throw en main flow.
  • No timeout: compensation activities no tienen timer boundary. Si quedan colgadas → incident.

Combinación: error + compensation

Pattern saga típico:

flowchart TD
    BF[Book flight] --> BH[Book hotel] --> BC[Book car] --> CO[Confirm]
    BF -.->|comp| CF[Cancel flight]
    BH -.->|comp| CH[Cancel hotel]
    BC -.->|comp| CC[Cancel car]
    BC -.->|error code='payment-failed'| TC["Throw compensate<br/>(no activityRef)"]
    TC --> EF[End: failed]

Implementación:

  1. Book car falla con BPMN error payment-failed.
  2. Engine encuentra boundary error event en el subprocess raíz que dispara Throw compensate.
  3. Throw compensate activa compensation handlers en orden LIFO:
  4. Cancel car (último completado — pero esperá, falló, así que NO se compensa).
  5. Cancel hotel.
  6. Cancel flight.
  7. Cuando todos terminan → llega a End: failed.

Patterns útiles

Retry-then-error

Para fallas técnicas con backoff antes de boundary error:

flowchart LR
    A["Service task<br/>taskDefinition retries=3, backoff=expo"] -->|retries=0| B["boundary error 'max-retries'"]
    B --> C[notify ops]

Saga compensation con confirmación

Algunos sistemas requieren "confirmation" post-compensation:

flowchart TD
    BF[Book flight] --> BH[Book hotel] --> BC[Book car] --> P[Pay] --> SC[Send confirmation]
    P -.->|error='payment-failed'| TC[Throw compensate]
    TC --> LIFO[Comps run in LIFO order]
    LIFO --> SFN[Send failure notification]
    SFN --> E[End]

Compensation con timeout

Si compensation puede colgarse, agregar timer boundary al compensation handler:

flowchart LR
    A[Cancel flight] -.->|timer 5min| B[Manual review]

Testing semántico

Tests obligatorios para garantizar implementación correcta:

func TestCompensationOrderLIFO(t *testing.T) {
    // bookFlight, bookHotel, bookCar todos completan
    // throw compensate
    // assert: cancelCar runs first, then cancelHotel, then cancelFlight
}

func TestUncompletedActivityNotCompensated(t *testing.T) {
    // bookFlight completes, bookHotel is active
    // throw compensate
    // assert: cancelFlight runs, cancelHotel does NOT
}

func TestErrorBubblingToParent(t *testing.T) {
    // subprocess raises error, no handler in subprocess
    // assert: error bubbles to parent, handler there fires
}

func TestVariablesSnapshotPreserved(t *testing.T) {
    // bookFlight completes with flightId=X
    // process variables change to flightId=Y
    // compensate → cancelFlight receives flightId=X
}

Métricas operacionales

wf_engine_bpmn_errors_thrown_total{process_id, error_code}
wf_engine_bpmn_errors_caught_total{process_id, error_code, handler_scope}
wf_engine_bpmn_errors_uncaught_total{process_id}  -- alerta!
wf_engine_compensations_triggered_total{process_id}
wf_engine_compensations_executed_total{process_id, handler_element}
wf_engine_compensations_failed_total{process_id, handler_element}
wf_engine_incidents_created_total{error_type}
wf_engine_incidents_resolved_total{error_type, resolution_time_bucket}

Alerta clave: rate(wf_engine_bpmn_errors_uncaught_total[5m]) > 0.01 → bug en modelo (error sin handler).

Edge cases tricky

  1. Compensation en multi-instance: cada child compensa por separado, en LIFO.
  2. Error de un compensation handler: NO dispara otra compensation. Va a incident.
  3. Compensation en event subprocess: se ejecuta en el contexto del event sub, no del main flow.
  4. Error en parallel branch: solo interrumpe esa branch. Para terminar todo, usar Terminate End.
  5. BPMN error vs incident: BPMN error es declarado (boundary handler existe); incident es uncaught.

Referencias