Zum Inhalt springen
Prozessautomatisierung

Señal o mensaje en BPMN: por qué una difusión deja procesos esperando para siempre

Un signal en BPMN es una difusión. Va a todos los que en ese momento están esperándolo, y a nadie más. Quien llega un segundo tarde no recibe nada, y tampoco se entera. Eso no es la debilidad de un motor concreto, es la definición del constructo, y justo por eso con él te montas de forma fiable un proceso que se queda parado en silencio.

Este artículo es un tutorial completo y no da por supuesto ningún conocimiento de BPMN. Construye paso a paso una entrada de mercancía de comercio online, provoca el fallo a propósito, lo enseña en un test, repara a mano las instancias colgadas y al final contrapone dos caminos que cierran la ventana de tiempo de forma duradera. Todos los ejemplos están escritos contra Flowable 8.

Contenido

La entrega que se queda parada en la entrada de mercancía

Una tienda online vende herramienta: taladros, sierras de calar, atornilladores a batería, además de accesorios y repuestos. El reaprovisionamiento llega por palés, una entrega abarca cuarenta posiciones, y cada posición es una referencia de artículo con una cantidad.

La entrada de mercancía corre como proceso. Para la entrega en conjunto hay un proceso recolector, para cada posición uno propio. El motivo es el de siempre: las posiciones terminan a velocidades distintas, una puede desviarse al control de calidad, otra ya está contabilizada, y nadie quiere mantener cuarenta ramas en un único diagrama.

El recorrido es sencillo. El proceso recolector arranca un proceso por posición. Cada posición busca una ubicación de almacén, y eso tarda, porque un servicio externo asigna el hueco libre. Después todas las posiciones esperan hasta que el albarán está revisado y la entrega liberada. Solo entonces se puede contabilizar, porque antes la mercancía está en la estantería pero todavía no pertenece a la tienda.

La liberación viene del proceso recolector, y como afecta a todas las posiciones a la vez, un signal era la elección más a mano. Un lanzamiento, cuarenta receptores.

Un martes se quedan paradas catorce posiciones. Ningún error, ningún incidente. El proceso recolector hace rato que siguió, la entrega cuenta como liberada, y aun así una parte de la mercancía no se contabilizó nunca. Se notó porque el proveedor preguntó por qué no llegaba el aviso de entrada de mercancía.

El resto de este artículo construye exactamente esa entrada de mercancía, la deja parada y luego la vuelve a poner en marcha.

Qué es un signal

Un signal es una difusión. Tiene un nombre, y nada más. Quien lo lanza no se dirige a nadie. Quien lo captura se ha registrado antes bajo ese nombre.

Ese registro se llama subscription y es el punto clave. El motor lleva una tabla de quién está esperando ahora mismo a qué nombre, en Flowable es ACT_RU_EVENT_SUBSCR. Al lanzar, mira en esa tabla, entrega a todos los que figuran ahí, y ha terminado.

El signal en sí no se guarda. No hay ningún depósito en el que un signal lanzado espere a interesados posteriores. Quien se registre un segundo después del lanzamiento no encuentra nada, y el motor tampoco tiene forma de avisarle, porque en el momento del lanzamiento ese receptor no existía.

Así que un signal solo llega a los receptores que ya están esperando en el momento del lanzamiento. Todo lo demás de este texto es consecuencia de eso.

Un message es la contraparte. Va direccionado, va a exactamente una instancia que espera, y el emisor tiene que indicar a cuál se refiere. Suena a más trabajo y justo ahí está su ventaja, allí donde alguien tiene que saber si la entrega llegó.

Lo decisivo para todo lo que sigue no es sin embargo la elección entre los dos, sino el momento del registro. Quien está registrado a tiempo tampoco se pierde un signal.

Qué necesitas

Para este tutorial necesitas un JDK, Maven o Gradle y un proyecto de Spring Boot con el starter de Flowable. Una base de datos no hace falta, la embebida basta, y para los tests es de todos modos la opción más sencilla.

<dependency>
  <groupId>org.flowable</groupId>
  <artifactId>flowable-spring-boot-starter</artifactId>
  <version>8.0.0</version>
</dependency>

Los modelos de proceso están como BPMN-XML bajo src/main/resources/processes/, desde ahí Flowable los despliega solo al arrancar. Para modelar sirve cualquier editor BPMN, para este tutorial basta el editor de texto, porque los modelos se quedan pequeños y se explica cada línea.

Todo lo de aquí vale para Flowable 8. Ten en cuenta que esa línea presupone Spring Boot 4 y Spring Framework 7. Quien siga sobre Spring Boot 3 se queda en la línea 7 de Flowable, y eso no cambia nada de los modelos ni de la API que se muestra aquí. Los constructos en sí son estándar BPMN y se comportan igual en otros motores, lo que cambia son los espacios de nombres en el XML y la API de Java.

Los dos procesos

El proceso recolector es el más corto. Crea un proceso por cada posición, espera a la liberación del albarán y la pasa adelante.

<process id="entrada-mercancia" name="Entrada de mercancía entrega">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="iniciar-posiciones"/>

  <serviceTask id="iniciar-posiciones" name="Iniciar posiciones"
               flowable:type="external-worker"
               flowable:topic="iniciar-posiciones"/>
  <sequenceFlow sourceRef="iniciar-posiciones" targetRef="esperar-liberacion"/>

  <intermediateCatchEvent id="esperar-liberacion" name="Albarán liberado">
    <messageEventDefinition messageRef="albaran-liberado"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="esperar-liberacion" targetRef="lanzar-liberacion"/>

  <intermediateThrowEvent id="lanzar-liberacion" name="Liberar posiciones">
    <signalEventDefinition signalRef="liberar-posiciones"/>
  </intermediateThrowEvent>
  <sequenceFlow sourceRef="lanzar-liberacion" targetRef="fin"/>

  <endEvent id="fin"/>
</process>

El proceso de posición busca una ubicación de almacén, espera a la liberación y contabiliza.

<process id="entrada-mercancia-posicion" name="Entrada de mercancía posición">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="reservar-ubicacion"/>

  <serviceTask id="reservar-ubicacion" name="Reservar ubicación"
               flowable:type="external-worker"
               flowable:topic="reservar-ubicacion"/>
  <sequenceFlow sourceRef="reservar-ubicacion" targetRef="esperar-liberacion"/>

  <intermediateCatchEvent id="esperar-liberacion" name="Esperar liberación">
    <signalEventDefinition signalRef="liberar-posiciones"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="esperar-liberacion" targetRef="contabilizar"/>

  <serviceTask id="contabilizar" name="Contabilizar stock"
               flowable:type="external-worker"
               flowable:topic="contabilizar-stock"/>
  <sequenceFlow sourceRef="contabilizar" targetRef="fin"/>

  <endEvent id="fin"/>
</process>

El signal y el message se declaran una vez en el nivel superior, fuera de los procesos, y luego se referencian con signalRef o con messageRef:

<signal id="liberar-posiciones" name="liberar-posiciones"/>
<message id="albaran-liberado" name="albaran-liberado"/>

Dos detalles merecen una segunda mirada. El signal no lleva ninguna referencia a una entrega concreta, se llama simplemente liberar-posiciones. Sobre eso vuelve la sección del signal global. Y el paso anterior a la espera es un external worker task, o sea, no código dentro del motor, sino una tarea que recoge un proceso aparte. Justo ese paso levanta la ventana de tiempo.

Hay un requisito que está en el primer paso y que hace falta más adelante: el worker detrás de iniciar-posiciones le pasa a cada posición la entregaId como variable de proceso al arrancarla. Sin ella no se puede distinguir después qué instancia en espera pertenece a qué entrega, y justo esa distinción es la que sostienen la reparación y el segundo camino.

El lanzamiento y la captura

Un signal se lanza o bien en el modelo, como arriba con el intermediateThrowEvent, o bien desde fuera a través de la API:

runtimeService.signalEventReceived("liberar-posiciones");

Con variables, que llegan a todos los receptores:

Map<String, Object> variables = Map.of("liberadoEl", LocalDate.now().toString());
runtimeService.signalEventReceived("liberar-posiciones", variables);

Y, esto es importante para la reparación de después, dirigido a una única execution que espera:

runtimeService.signalEventReceived("liberar-posiciones", executionId);

La captura pasa en el modelo y solo ahí. Una instancia captura un signal estando parada en un catch event. No se puede registrar por adelantado, no puede preguntar si se ha perdido algo, y no percibe que se acaba de lanzar.

El proceso de posición depende así por completo de estar a tiempo en esperar-liberacion.

Dónde nace realmente la subscription

La subscription no nace cuando el proceso arranca, ni cuando en algún momento vaya a alcanzar el catch event. Nace cuando la ejecución llega ahí y la transacción está confirmada. Antes de eso en ACT_RU_EVENT_SUBSCR no hay nada, y para el motor ese que espera no existe.

Delante de eso está en nuestro modelo reservar-ubicacion. Un external worker task significa: el motor crea una tarea y espera. Un proceso externo pregunta a intervalos por tareas abiertas, coge una, la resuelve y devuelve el resultado. Entre la creación y la respuesta están el intervalo de sondeo, la red, el trabajo en sí y otra confirmación.

Para cuarenta posiciones eso significa que no llegan juntas al catch event, sino repartidas a lo largo de segundos. Quien consigue rápido una ubicación está ahí pronto. Quien espera un hueco en una estantería llena está ahí tarde.

El proceso recolector no sabe nada de eso. Solo espera a la liberación del albarán, y esa llega cuando llega. Si cae dentro de esa ventana, el lanzamiento alcanza a una parte de las posiciones y al resto no.

Ese es el fallo entero. No necesita ningún bug ni ninguna caída, solo dos cosas que pueden ser rápidas o lentas de forma independiente entre sí.

Hacer visible el fallo en un test

Un fallo que no se puede reproducir va a sorprender igual la próxima vez. El test es corto, porque la ventana de tiempo se deja fabricar sin esfuerzo en un test: a una posición la dejas alcanzar el catch event, a la otra no.

@Test
void shouldLoseSignalWhenPositionIsStillInWorkerTask() {
    ProcessInstance rapida = runtimeService.startProcessInstanceByKey("entrada-mercancia-posicion");
    ProcessInstance lenta = runtimeService.startProcessInstanceByKey("entrada-mercancia-posicion");

    completarReservaUbicacion(rapida);

    runtimeService.signalEventReceived("liberar-posiciones");

    completarReservaUbicacion(lenta);

    assertThat(actividadActiva(rapida)).isEqualTo("contabilizar");
    assertThat(actividadActiva(lenta)).isEqualTo("esperar-liberacion");
}

La segunda aserción es la afirmación de verdad: la posición lenta está en esperar-liberacion y ahí se queda, de forma permanente. No hay un segundo lanzamiento, ni reintento, ni reprogramación, porque el motor ni siquiera sabe que aquí alguien se ha perdido algo.

Aquí hay dos ayudantes abreviados. completarReservaUbicacion cierra el job de external worker de la instancia que se le pasa, y eso va por el ManagementService con createExternalWorkerJobAcquireBuilder, el builder de cierre y la asignación del job por su id de instancia de proceso. Esa asignación es la parte que no se puede omitir, porque con dos jobs abiertos el test podría cerrar la posición equivocada. actividadActiva lee la actividad activa de la instancia.

Un test así va en la suite de tests y no en un cuaderno. Describe el comportamiento que quieres cambiar, y se pone en rojo en cuanto el cambio surte efecto. Justo entonces lo reescribes, en lugar de borrarlo.

El diagnóstico: quién espera, y a qué

En un entorno en marcha la primera pregunta es: quién está parado en ese catch event, y bajo qué nombre está registrado.

List<Execution> esperando = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("liberar-posiciones")
        .list();

Hay que distinguir dos casos, y llevan a reparaciones muy distintas.

Si ahí figura el nombre esperado y la lista no está vacía, fue cosa del timing. Las instancias están registradas correctamente, solo se perdieron el lanzamiento. Un lanzamiento nuevo ayuda.

Si la lista está vacía aunque hay instancias paradas en el evento, el nombre no cuadra. Eso pasa cuando el nombre del signal se forma a partir de una variable y la variable estaba vacía al crear la subscription. Entonces no ayuda ningún lanzamiento, porque nadie escucha bajo el nombre que tú lanzas. Este caso solo lo reconoce quien ya lo conoce, y es el más desagradable.

A eso se suma la contraprueba funcional, que no tiene nada que ver con el motor: cuántas posiciones de esa entrega están contabilizadas y cuántas no. Si el número de instancias colgadas coincide con el número de contabilizaciones que faltan, el diagnóstico está redondo.

La reparación inmediata

En un entorno de producción primero quieres dejar seguir a las catorce posiciones y después arreglar la causa con calma. El lanzamiento se puede recuperar:

runtimeService.signalEventReceived("liberar-posiciones", variables);

Ahí entran en juego las variables y el alcance, y las dos cosas ya han hecho daño alguna vez.

Primero, las variables. Si el lanzamiento del modelo lleva datos consigo y un paso posterior los lee, el lanzamiento recuperado tiene que llevar los mismos datos. Si faltan, la posición sigue adelante pero se estrella en la siguiente tarea, y de una instancia que esperaba sale un incidente. Eso es una mejora, pero no una reparación.

Segundo, el alcance. El lanzamiento sin indicar una execution va a todos los que están esperando, así que posiblemente también a posiciones de una entrega completamente distinta, que están esperando con todo el derecho a su propia liberación. Quien no quiera eso, acota primero el conjunto a su propia entrega y después entrega una a una:

List<Execution> esperando = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("liberar-posiciones")
        .processVariableValueEquals("entregaId", entregaId)
        .list();

for (Execution execution : esperando) {
    runtimeService.signalEventReceived("liberar-posiciones", execution.getId(), variables);
}

El filtro por la variable de proceso no es un detalle. La consulta de la sección de diagnóstico no lo llevaba, porque ahí se trataba de ver a todos los que esperan. Quien la reutilice tal cual para la reparación entrega al mismo conjunto exacto que la difusión y no ha ganado nada.

Ese bucle saca adelante la entrega. La causa no la quita, porque la próxima vez alguien volverá a llegar tarde al catch event. Para eso hay dos caminos, y los dos se sostienen.

El primer camino: adelantar el registro

La ventana de tiempo no la abre el signal, la abre un registro que llega tarde. Quien lo adelanta se puede quedar con la difusión.

Para eso el proceso de posición recibe un parallel gateway justo después del arranque. Una rama va directa al catch event y se registra, la otra reserva la ubicación de almacén. Un join vuelve a juntar las dos, y solo después se contabiliza.

<process id="entrada-mercancia-posicion" name="Entrada de mercancía posición">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="dividir"/>

  <parallelGateway id="dividir"/>
  <sequenceFlow sourceRef="dividir" targetRef="esperar-liberacion"/>
  <sequenceFlow sourceRef="dividir" targetRef="reservar-ubicacion"/>

  <intermediateCatchEvent id="esperar-liberacion" name="Esperar liberación">
    <signalEventDefinition signalRef="liberar-posiciones"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="esperar-liberacion" targetRef="unir"/>

  <serviceTask id="reservar-ubicacion" name="Reservar ubicación"
               flowable:type="external-worker"
               flowable:topic="reservar-ubicacion"/>
  <sequenceFlow sourceRef="reservar-ubicacion" targetRef="unir"/>

  <parallelGateway id="unir"/>
  <sequenceFlow sourceRef="unir" targetRef="contabilizar"/>

  <serviceTask id="contabilizar" name="Contabilizar stock"
               flowable:type="external-worker"
               flowable:topic="contabilizar-stock"/>
  <sequenceFlow sourceRef="contabilizar" targetRef="fin"/>

  <endEvent id="fin"/>
</process>

Con eso la subscription nace en la misma transacción que el arranque del proceso. El lanzamiento ya no la puede fallar, porque el proceso recolector arranca las posiciones él mismo y lanza solo después. Si el signal dispara mientras la reserva sigue en marcha, la rama del signal espera en el join a su vecina lenta.

Hay una condición dura. El camino desde el arranque hasta el catch event tiene que seguir siendo síncrono. Si ahí hay un flowable:async, la subscription no nace hasta que el job executor ejecuta el job, y la ventana vuelve a estar abierta.

El alcance se queda intacto. La difusión sigue siendo global y alcanza también posiciones de otras entregas, sobre eso va la sección del signal global.

El segundo camino: un message por posición

En lugar de un signal a todos, cada posición recibe su propio message. En el modelo cambia exactamente una línea:

<intermediateCatchEvent id="esperar-liberacion" name="Esperar liberación">
  <messageEventDefinition messageRef="posicion-liberada"/>
</intermediateCatchEvent>
<message id="posicion-liberada" name="posicion-liberada"/>

En el proceso recolector, el único lanzamiento se convierte en un bucle sobre las posiciones de la entrega. El emisor busca la instancia que espera y entrega:

public void liberarPosiciones(String entregaId, Map<String, Object> variables) {
    List<Execution> esperando = runtimeService.createExecutionQuery()
            .messageEventSubscriptionName("posicion-liberada")
            .processVariableValueEquals("entregaId", entregaId)
            .list();

    for (Execution execution : esperando) {
        runtimeService.messageEventReceived("posicion-liberada", execution.getId(), variables);
    }
}

La ganancia no está en que de repente ya nadie pueda llegar tarde. Puede llegar tarde. La ganancia es que se hace visible y se puede corregir.

El emisor conoce el número de posiciones de esa entrega. Si encuentra menos instancias esperando de las previstas, lo sabe al momento y puede registrarlo, reintentar más tarde o provocar un error. Con el signal nunca tuvo esa información, porque un lanzamiento sin receptores tiene exactamente la misma pinta que un lanzamiento a cuarenta.

Por qué un message tampoco almacena

Un malentendido extendido suele aparecer como comentario debajo de textos así, conviene adelantarse.

Un message en Flowable se almacena igual de poco que un signal. Si en el momento de la entrega no existe una subscription que encaje, el message también desaparece. Así que no va de durabilidad.

Va del direccionamiento, y de ahí sale todo lo demás. Como el emisor se refiere a una execution concreta, puede comprobar si existe. Si no la encuentra, tiene un hallazgo en lugar de una sospecha, y como la entrega va a una instancia concreta, se puede repetir sin riesgo.

Repetir es tarea de quien llama, no del motor. Flowable no trae ninguna reprogramación automática para una entrega fallida. Quien la quiera, se la construye: con una cola que vuelve a entregar, con un job que recoge las posiciones abiertas, o con el timer de la siguiente sección.

Hay motores que guardan los messages con un tiempo de vida y así cierran la ventana por su cuenta. Quien vaya sobre uno de esos no tiene este problema en esta forma. Eso no cambia nada del comportamiento de los signals, porque tampoco allí los almacena nadie.

Cuando hay muchísimos receptores

Con cuarenta posiciones el bucle no se nota. Con unos miles sí, y entonces la difusión pesa, porque un lanzamiento sustituye mil llamadas sueltas.

La distancia no es tan grande. La difusión también entrega una a una, el motor lee las subscriptions y las recorre. Lo que se ahorra no es la entrega en sí, sino resolver los receptores del lado de quien llama y el camino hasta ahí por la API.

El bucle se pone caro sobre todo donde cada entrega recibe su propia transacción. Si la llamada corre fuera de una transacción existente, Flowable abre una nueva por comando, y entonces pagas mil confirmaciones en lugar de unas pocas. Dos ajustes mantienen eso a raya. El bucle va en una transacción por lote y no en una por entrega, y una continuación asíncrona detrás del catch event traslada el trabajo posterior al job executor, repartido y en paralelo.

Aun así la difusión sigue en ventaja con volúmenes grandes, un comando y una consulta de las subscriptions en lugar de mil. Con el registro adelantado del primer camino también está a salvo de la ventana de tiempo, mientras que el alcance sigue siendo el tema de la sección del signal global. Quien se decida por el message paga con rendimiento y a cambio recibe el hallazgo de qué receptores faltaron.

Donde el volumen se pone de verdad grande, compensa además mirar el modelado. Mil instancias de proceso propias cuestan al margen de si las despierta un signal o un message. Un subproceso multi-instance convierte mil entregas en una, siempre que el paso de espera suba al nivel del proceso y las posiciones sigan solo después. A cambio te llevas un recorrido vital común para todas las posiciones.

El timer como segundo seguro

Al margen de si es signal o message, queda una pregunta abierta: qué pasa si la liberación no llega en absoluto. Un proceso que espera sin plazo espera para siempre, y en la monitorización eso tiene la misma pinta que uno que todavía está trabajando.

Un timer en el paso de espera convierte eso en un estado visible. Ahí hay una restricción que se pasa por alto con facilidad: un boundary event necesita una actividad de la que colgar. Un catch event no lo es. Si aun así le pones el attachedToRef al evento de espera, Flowable descarta el enlace en silencio. El modelo despliega, el timer aparece en el diagrama, y no dispara nunca.

Por eso el paso de espera se muda a un subproceso embebido, y el timer cuelga del borde de ese subproceso:

<subProcess id="esperar-con-plazo" name="Esperar a la liberación">
  <startEvent id="espera-inicio"/>
  <sequenceFlow sourceRef="espera-inicio" targetRef="esperar-liberacion"/>
  <intermediateCatchEvent id="esperar-liberacion" name="Esperar liberación">
    <messageEventDefinition messageRef="posicion-liberada"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="esperar-liberacion" targetRef="espera-fin"/>
  <endEvent id="espera-fin"/>
</subProcess>

<boundaryEvent id="liberacion-vencida" attachedToRef="esperar-con-plazo"
               cancelActivity="false">
  <timerEventDefinition>
    <timeDuration>PT30M</timeDuration>
  </timerEventDefinition>
</boundaryEvent>

Con cancelActivity="false" el subproceso sigue en marcha, el timer solo se desvía. Ahí cuelgas lo que deba pasar en ese caso: una tarea para la entrada de mercancía, un aviso a la monitorización, o un nuevo intento de entrega. La subscription de la execution dentro del subproceso sigue siendo alcanzable para la consulta y para la entrega, así que en los dos caminos no cambia nada.

El timer no arregla la causa, la ventana de tiempo se queda exactamente igual de grande que antes. Solo transforma la espera silenciosa en un caso que alguien ve. Es poco y aun así es la diferencia entre un fallo que se nota el mismo día y otro que denuncia el proveedor.

La trampa en el tratamiento de errores

Hay un segundo camino, del todo independiente, hacia el mismo daño, y está fuera del modelo de proceso.

En paisajes así, los messages rara vez llegan directos, sino a través de una cola o un topic. El receptor recoge el message, lo entrega a la instancia de proceso y después lo confirma.

Qué pinta tiene un fallo aquí depende del camino, y eso se confunde a menudo. Si entregas directamente a un id de execution que ya no existe, el motor lanza un error. Si en cambio primero buscas las instancias que esperan y luego entregas, como en el segundo camino de arriba, no salta nada. La lista está simplemente vacía, el bucle no corre, y el código tiene la pinta de que todo ha ido bien.

El fallo habitual en el tratamiento de errores tiene esta pinta:

try {
    procesoService.liberarPosiciones(entregaId, variables);
} catch (FlowableException e) {
    log.info("Liberacion ya realizada, se confirma el mensaje");
    acknowledge(mensaje);
}

Aquí se tratan igual dos casos distintos: la liberación ya se había producido, o la entrega no alcanzó a nadie. El primer caso es inofensivo y la confirmación es correcta. En el segundo se confirma un message que no ha surtido efecto, y después ya no está.

Lo limpio es separar los casos antes de confirmar:

List<Execution> esperando = buscarPosicionesEsperando(entregaId);
if (esperando.isEmpty() && todasYaContabilizadas(entregaId)) {
    acknowledge(mensaje);
    return;
}
if (esperando.isEmpty()) {
    throw new IllegalStateException("No hay posicion esperando para la entrega " + entregaId);
}

La regla que hay detrás es más general que BPMN: no confirmes nunca un message cuyo efecto no hayas comprobado. Un tratamiento de errores que no distingue el éxito del fracaso convierte un fallo ruidoso en uno silencioso.

El signal global alcanza demasiado

Para dejarlo completo, el otro daño que puede causar la misma difusión, y que en la historia inicial no apareció solo porque siempre corría una única entrega a la vez.

Un signal es global por defecto. Alcanza a cada instancia que espera con el nombre que encaje, cruzando definiciones de proceso e instancias. Si llegan dos entregas a la vez, la liberación de una libera también las posiciones de la otra, y sin que se note en ningún sitio. Se contabiliza mercancía que todavía no está liberada.

Para el caso de que un signal deba actuar de verdad solo dentro de una instancia de proceso, Flowable conoce un atributo:

<signal id="liberar-posiciones" name="liberar-posiciones"
        flowable:scope="processInstance"/>

Eso no resuelve nuestro problema, porque el proceso recolector y las posiciones son instancias de proceso propias. La difusión tendría entonces que cruzar el límite de la instancia y al mismo tiempo estar limitada a una entrega, y justo esa combinación el constructo no la da. También eso es un argumento a favor del message direccionado.

¿Signal, message o timer?

Signal Message Timer
Direccionamiento difusión a todos los que esperan exactamente una execution ninguno, actúa en el sitio
Alcance global, limitable de forma opcional a la instancia la instancia direccionada la propia instancia
El emisor se entera del fallo no no aplica
Repetible solo como nueva difusión a todos sí, de forma dirigida no aplica
Almacenado no no no aplica
Coste con muchos receptores un lanzamiento una entrega por instancia no aplica
Encaja para cancelación, escalado, «parar todo», liberaciones a muchos con registro adelantado liberaciones, confirmaciones, entregas plazos, escalado por tiempo

Deciden dos filas, y apuntan en direcciones distintas. Que el emisor se entere del fallo decide si de un momento perdido sale un caso a tratar o un enigma. El coste con muchos receptores tira hacia el otro lado. Ninguna de las dos filas toca la ventana de tiempo, esa la cierra solo el registro adelantado.

Los ficheros completos

El proceso de posición en el segundo camino, con message en lugar de signal y con timer. Quien vaya por el primer camino vuelve a poner aquí el signal y se queda con el parallel gateway de la sección de arriba:

<process id="entrada-mercancia-posicion" name="Entrada de mercancía posición">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="reservar-ubicacion"/>

  <serviceTask id="reservar-ubicacion" name="Reservar ubicación"
               flowable:type="external-worker"
               flowable:topic="reservar-ubicacion"/>
  <sequenceFlow sourceRef="reservar-ubicacion" targetRef="esperar-con-plazo"/>

  <subProcess id="esperar-con-plazo" name="Esperar a la liberación">
    <startEvent id="espera-inicio"/>
    <sequenceFlow sourceRef="espera-inicio" targetRef="esperar-liberacion"/>
    <intermediateCatchEvent id="esperar-liberacion" name="Esperar liberación">
      <messageEventDefinition messageRef="posicion-liberada"/>
    </intermediateCatchEvent>
    <sequenceFlow sourceRef="esperar-liberacion" targetRef="espera-fin"/>
    <endEvent id="espera-fin"/>
  </subProcess>
  <sequenceFlow sourceRef="esperar-con-plazo" targetRef="contabilizar"/>

  <boundaryEvent id="liberacion-vencida" attachedToRef="esperar-con-plazo"
                 cancelActivity="false">
    <timerEventDefinition>
      <timeDuration>PT30M</timeDuration>
    </timerEventDefinition>
  </boundaryEvent>
  <sequenceFlow sourceRef="liberacion-vencida" targetRef="reclamar-liberacion"/>

  <serviceTask id="reclamar-liberacion" name="Reclamar liberación"
               flowable:type="external-worker"
               flowable:topic="reclamar-liberacion"/>
  <sequenceFlow sourceRef="reclamar-liberacion" targetRef="fin-reclamacion"/>
  <endEvent id="fin-reclamacion"/>

  <serviceTask id="contabilizar" name="Contabilizar stock"
               flowable:type="external-worker"
               flowable:topic="contabilizar-stock"/>
  <sequenceFlow sourceRef="contabilizar" targetRef="fin"/>

  <endEvent id="fin"/>
</process>

La entrega, con la comprobación que antes faltaba:

@Service
public class LiberacionService {

    private final RuntimeService runtimeService;

    public LiberacionService(RuntimeService runtimeService) {
        this.runtimeService = runtimeService;
    }

    public int liberarPosiciones(String entregaId, Map<String, Object> variables) {
        List<Execution> esperando = runtimeService.createExecutionQuery()
                .messageEventSubscriptionName("posicion-liberada")
                .processVariableValueEquals("entregaId", entregaId)
                .list();

        for (Execution execution : esperando) {
            runtimeService.messageEventReceived(
                    "posicion-liberada", execution.getId(), variables);
        }
        return esperando.size();
    }
}

Devolver el número es toda la diferencia con el signal. Quien llama lo compara con el número de posiciones abiertas y sabe si ha terminado.

Trampas frecuentes

  • El nombre del signal se forma a partir de una variable y la variable está vacía al crear la subscription. La instancia espera entonces bajo un nombre que nadie lanza. La mirada a las subscriptions lo enseña, el modelo no.
  • El lanzamiento recuperado no lleva las variables consigo. La instancia sigue adelante y se estrella en el siguiente paso. De la espera silenciosa sale un incidente, eso es mejor, pero no es el objetivo.
  • Una difusión de reparación alcanza a instancias ajenas. Con varios casos a la vez, la entrega dirigida por execution es la única variante segura.
  • Se toma el timer por la solución. Hace visible el problema y no cierra la ventana de tiempo.
  • El tratamiento de errores confirma un message cuya entrega se fue al vacío. Ese es el segundo camino, independiente, hacia exactamente el mismo daño.
  • Se mete un paso asíncrono delante del paso de espera, mucho después de que el modelo naciera. La ventana de tiempo aparece entonces a posteriori, sin que nadie haya tocado nada del signal.
  • La rama paralela hacia el catch event recibe un flowable:async. Con eso la subscription no nace hasta que corre el job executor, y el registro adelantado se pierde otra vez.

Cuándo un signal sí es lo correcto

Después de esta colección se podría pensar que el constructo hay que retirarlo. No es así, solo está hecho para otra cosa.

Un signal encaja cuando el círculo de receptores es abierto y debe seguir siéndolo. «Cancelar todas las revisiones en curso de este proveedor» es justo eso: el emisor no sabe cuántas son, y tampoco quiere saberlo. Si una instancia llega tarde, no hay daño, porque de todos modos ya no tendría nada que hacer.

Encaja además cuando el signal es un estado y no una señal de salida. Un proceso que comunica un modo de operación lo puede hacer por difusión, mientras cada instancia pueda además consultar el modo, en lugar de fiarse solo del momento.

Y encaja cuando muchos receptores necesitan la misma liberación y el registro está hecho pronto. Entonces la difusión saca su fuerza, una llamada en lugar de mil, sin la ventana de tiempo como precio.

El límite pasa por donde la difusión se convierte en una entrega que alguien tiene que seguir. En cuanto deba notarse que una instancia concreta no ha reaccionado, solo el message direccionado da ese hallazgo.

FAQ

¿Flowable almacena los signals lanzados?
No. Un signal alcanza exclusivamente a las subscriptions que existen en el momento del lanzamiento. No hay depósito para receptores posteriores ni reprogramación.

¿Resuelve el problema un lanzamiento asíncrono?
No. También un lanzamiento ejecutado de forma asíncrona determina los receptores en el momento en el que corre de verdad. El retardo desplaza la ventana de tiempo, no la cierra.

¿Y los messages sí se almacenan?
En Flowable no. La diferencia está en el direccionamiento: el emisor se refiere a una instancia concreta, por eso se da cuenta si falta, y puede repetir de forma dirigida.

¿Tengo que pasarme a un message por la ventana de tiempo?
No. Una rama paralela que va al catch event justo después del arranque registra la instancia antes de que se pueda lanzar nada. El signal se queda donde está. El message hace falta cuando el emisor tiene que saber si la entrega llegó.

¿No se vuelve demasiado lenta la entrega por posición con muchos receptores?
Cuesta más que un lanzamiento, pero menos de lo que parece, porque la difusión también entrega una a una. Lo que pesa es el límite de la transacción y el trabajo posterior detrás del catch event. Con volúmenes muy grandes la difusión sigue en ventaja, y con el registro adelantado la ventana de tiempo también queda cerrada.

¿Cómo averiguo si fue cosa del timing?
Por las event subscriptions. Si el nombre esperado está ahí y la instancia espera igualmente, fue el timing. Si ahí hay otro nombre o ninguno, la subscription estuvo mal desde el principio.

¿Puedo limitar un signal a una instancia de proceso?
Sí, con flowable:scope="processInstance" en la declaración del signal. Pero eso solo ayuda dentro de una instancia. Para instancias de proceso relacionadas pero independientes, el camino pasa por el message.

¿Basta un boundary timer como salvaguarda?
Hace visible la espera y por eso casi siempre tiene sentido. La causa no la elimina, la ventana de tiempo se queda.

¿Qué pasa con multi-instance en lugar de procesos propios por posición?
Con eso la pregunta desaparece, porque ya no hay instancias separadas que puedan perderse algo. A cambio te llevas un modelo en el que todas las posiciones tienen el mismo recorrido vital y un fallo en una posición toca a las demás. Eso es una ponderación y no una solución del problema del signal.

Conclusión

Un signal es una apuesta a que todos los receptores estén registrados a tiempo. Mientras no haya nada por medio, la ganas cada vez. En cuanto delante del paso de espera hay algo asíncrono, y en un paisaje distribuido tarde o temprano ahí hay algo, la pierdes en algún momento, y la pérdida es invisible.

La apuesta se convierte en promesa en cuanto el registro deja de depender del azar. Una rama paralela que va directa al catch event resuelve eso en el modelo y deja la difusión donde está. Es el camino más barato, sobre todo con muchos receptores.

Quien además tenga que saber si la entrega llegó se pasa al message direccionado y lo paga con rendimiento. Con eso, de un momento perdido sale un caso que alguien puede tratar.

El siguiente paso sensato es pequeño: busca en tus modelos los catch events de signal y mira qué hay justo delante. Si es un paso asíncrono, un external worker o una llamada hacia fuera, entonces ya tienes esa ventana de tiempo, y la única pregunta abierta es cuándo te va a golpear la primera vez.

Fuentes

Todos los modelos, los ejemplos de Java y el escenario de la tienda online de herramienta son propios y están escritos contra Flowable 8. Los fragmentos de BPMN-XML están recortados a los elementos tratados, por eso faltan los espacios de nombres y la información de diagrama.

$ lang DE EN ES