Zum Inhalt springen
Prozessautomatisierung

BPMN signal or message: why a broadcast leaves processes waiting forever

A signal in BPMN is a broadcast. It goes to everyone who is waiting for it at that moment, and to nobody else. Whoever arrives a second too late gets nothing, and does not learn about it either. That is not the weakness of one particular engine, it is the definition of the construct, and it is exactly why you can reliably build yourself a process that comes to a standstill.

This post is a full tutorial and assumes no prior BPMN knowledge. It builds up a goods receipt in online retail step by step, brings about the mistake on purpose, shows it in a test, repairs the hanging instances by hand and in the end contrasts two ways of closing the time window for good. All examples are written against Flowable 8.

Contents

The delivery that gets stuck in goods receipt

An online shop sells tools: drills, jigsaws, cordless screwdrivers, plus accessories and spare parts. Restocking comes in by the pallet, one delivery covers forty items, and every item is an article number with a quantity.

The goods receipt runs as a process. For the delivery as a whole there is one collective process, for every item a process of its own. The reason is the usual one: items finish at different speeds, one can branch off into quality inspection, another is already booked in, and nobody wants to maintain forty branches in a single diagram.

The flow is simple. The collective process starts one process per item. Every item looks for a storage bin, which takes a while, because an external service hands out the free spot. After that all items wait until the delivery note has been checked and the delivery has been released. Only then may stock be booked, because before that the goods are on the shelf but do not yet belong to the shop.

The release comes from the collective process, and because it concerns all items at the same time, a signal was the obvious choice. One throw, forty receivers.

Then on a Tuesday fourteen items stand still. No error, no incident. The collective process moved on long ago, the delivery counts as released, and still part of the goods was never booked in. It was noticed because the supplier asked why the goods receipt notification was not coming.

The rest of this article builds exactly this goods receipt, brings it to a standstill and then gets it running again.

What a signal is

A signal is a broadcast. It has a name, nothing else. Whoever throws it addresses nobody. Whoever catches it registered under that name beforehand.

This registration is called a subscription and is the crux of the matter. The engine keeps a table of who is currently waiting for which name, in Flowable that is ACT_RU_EVENT_SUBSCR. On the throw it looks into this table, delivers to everyone listed there, and is done.

The signal itself is not remembered. There is no place where a thrown signal waits for later interested parties. Whoever registers a second after the throw finds nothing, and the engine has no way of informing them about it either, because at the moment of the throw they did not exist.

So a signal only transmits to the receivers that are already waiting at the moment of the throw. Everything else in this text is a consequence of it.

A message is the counterpart. It is addressed, it goes to exactly one waiting instance, and the sender has to state which one is meant. That sounds like more work and is exactly its advantage wherever somebody has to know whether the delivery arrived.

What matters for everything that follows, though, is not the choice between the two but the moment of registration. Whoever is registered in time does not miss a signal either.

What you need

For this tutorial you need a JDK, Maven or Gradle and a Spring Boot project with the Flowable starter. A database is not necessary, the embedded one is enough, and for the tests it is the simplest choice anyway.

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

The process models sit as BPMN XML under src/main/resources/processes/, from where Flowable deploys them itself at startup. For modelling, any BPMN editor will do, for this tutorial a text editor is enough, because the models stay small and every line is explained.

Everything here applies to Flowable 8. Note that this line requires Spring Boot 4 and Spring Framework 7. If you are still on Spring Boot 3, stay on the Flowable 7 line, which changes nothing about the models and about the API shown here. The constructs themselves are BPMN standard and behave the same in other engines, what differs are the namespaces in the XML and the Java API.

The two processes

The collective process is the shorter one. It creates a process for every item, waits for the release of the delivery note and passes it on afterwards.

<process id="goods-receipt" name="Goods receipt delivery">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="start-items"/>

  <serviceTask id="start-items" name="Start items"
               flowable:type="external-worker"
               flowable:topic="start-items"/>
  <sequenceFlow sourceRef="start-items" targetRef="await-release"/>

  <intermediateCatchEvent id="await-release" name="Delivery note released">
    <messageEventDefinition messageRef="delivery-note-released"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="await-release" targetRef="throw-release"/>

  <intermediateThrowEvent id="throw-release" name="Release items">
    <signalEventDefinition signalRef="release-items"/>
  </intermediateThrowEvent>
  <sequenceFlow sourceRef="throw-release" targetRef="end"/>

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

The item process looks for a storage bin, waits for the release and books.

<process id="goods-receipt-item" name="Goods receipt item">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="reserve-bin"/>

  <serviceTask id="reserve-bin" name="Reserve bin"
               flowable:type="external-worker"
               flowable:topic="reserve-bin"/>
  <sequenceFlow sourceRef="reserve-bin" targetRef="await-release"/>

  <intermediateCatchEvent id="await-release" name="Await release">
    <signalEventDefinition signalRef="release-items"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="await-release" targetRef="book-stock"/>

  <serviceTask id="book-stock" name="Book stock"
               flowable:type="external-worker"
               flowable:topic="book-stock"/>
  <sequenceFlow sourceRef="book-stock" targetRef="end"/>

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

Signal and message are declared once at the top level, outside the processes, and then referenced via signalRef and messageRef respectively:

<signal id="release-items" name="release-items"/>
<message id="delivery-note-released" name="delivery-note-released"/>

Two details are worth a second look. The signal carries no reference to a particular delivery, it is simply called release-items. The section about the global signal comes back to that. And the step before the waiting is an external worker task, so no code in the engine but a task that a separate process picks up. Exactly this step builds up the time window.

One precondition sits in the first step and is needed later: the worker behind start-items gives every item the deliveryId as a process variable at start. Without it there is no way to tell later which waiting instance belongs to which delivery, and exactly that distinction carries the repair and the second way.

The throw and the catch

You throw a signal either in the model, as above with the intermediateThrowEvent, or from the outside through the API:

runtimeService.signalEventReceived("release-items");

With variables that arrive at all receivers:

Map<String, Object> variables = Map.of("releasedOn", LocalDate.now().toString());
runtimeService.signalEventReceived("release-items", variables);

And, this matters for the repair later, targeted at a single waiting execution:

runtimeService.signalEventReceived("release-items", executionId);

The catch happens in the model and only there. An instance catches a signal by standing at a catch event. It cannot register as a precaution, it cannot ask whether it missed something, and it does not notice that a throw just happened.

The item process therefore depends entirely on standing at await-release in time.

Where the subscription really comes into being

The subscription does not come into being when the process starts, and not when it will reach the catch event at some point either. It comes into being when execution arrives there and the transaction is committed. Before that there is nothing in ACT_RU_EVENT_SUBSCR, and for the engine this waiting party does not exist.

Before it, in our model, sits reserve-bin. An external worker task means: the engine creates a task and waits. An external process asks at an interval for open tasks, takes one, works it off and reports the result back. Between the creation and the report back lie the polling interval, the network, the work itself and another commit.

For forty items that means: they do not arrive at the catch event together, but spread out over seconds. Whoever gets a storage bin quickly stands there early. Whoever waits for a spot in a full shelf stands there late.

The collective process knows nothing about that. It only waits for the release of the delivery note, and that comes when it comes. If it falls into this window, the throw hits some of the items and not the rest.

That is the whole mistake. It needs no bug and no outage, only two things that are allowed to be fast or slow independently of each other.

Making the mistake visible in a test

A mistake you cannot reproduce will surprise you the same way next time. The test for it is short, because the time window is easy to produce in a test: you let one item reach the catch event, the other one not.

@Test
void shouldLoseSignalWhenPositionIsStillInWorkerTask() {
    ProcessInstance fast = runtimeService.startProcessInstanceByKey("goods-receipt-item");
    ProcessInstance slow = runtimeService.startProcessInstanceByKey("goods-receipt-item");

    completeBinReservation(fast);

    runtimeService.signalEventReceived("release-items");

    completeBinReservation(slow);

    assertThat(activeActivity(fast)).isEqualTo("book-stock");
    assertThat(activeActivity(slow)).isEqualTo("await-release");
}

The second assertion is the actual statement: the slow item stands at await-release and stays there, permanently. There is no second throw, no retry and no follow-up, because the engine does not even know that somebody missed something here.

Two helpers are abbreviated here. completeBinReservation completes the external worker job of the given instance, which runs through the ManagementService with createExternalWorkerJobAcquireBuilder, the completion builder and the assignment of the job through its process instance id. That assignment is the part you must not leave out, otherwise the test may complete the wrong item when two jobs are open. activeActivity reads the active activity of the instance.

A test like this belongs in the test suite and not in a notebook. It describes the behaviour you want to change, and it turns red as soon as the change takes hold. That is the moment when you rewrite it instead of deleting it.

The diagnosis: who is waiting, and for what

In a running environment the first question is: who is standing at that catch event at all, and under which name are they registered?

List<Execution> waiting = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("release-items")
        .list();

Two cases have to be told apart, and they lead to completely different repairs.

If the expected name is there and the list is not empty, it was the timing. The instances are registered correctly, they only missed the throw. Another throw helps.

If the list is empty although instances are standing at the event, the name is wrong. That happens when the signal name is built from a variable and the variable was empty when the subscription was created. Then no throw helps, because nobody is listening under the name you are throwing. You only spot this case if you know it, and it is the more unpleasant one.

On top of that the business cross-check, which has nothing to do with the engine: how many items of this delivery are booked, and how many are not? If the number of hanging instances matches the number of missing bookings, the diagnosis holds.

The immediate repair

In a production environment you first want to get the fourteen items moving again and then fix the cause in peace. The throw can be made up for:

runtimeService.signalEventReceived("release-items", variables);

It comes down to the variables and to the reach, and both have already done damage.

First the variables. If the throw in the model passes data along and a subsequent step reads it, the repeated throw has to carry the same data. If it is missing, the item does move on, but fails at the next task, and a waiting instance turns into an incident. That is an improvement, but not a repair.

Second the reach. The throw without naming an execution goes to everyone who is currently waiting, so possibly also to items of a completely different delivery that are waiting entirely rightly for their own release. If you do not want that, you first narrow the set down to your own delivery and then deliver one by one:

List<Execution> waiting = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("release-items")
        .processVariableValueEquals("deliveryId", deliveryId)
        .list();

for (Execution execution : waiting) {
    runtimeService.signalEventReceived("release-items", execution.getId(), variables);
}

The filter on the process variable is no detail here. The query from the diagnosis section did not have it, since it was meant to show everyone who is waiting. If you reuse it unchanged for the repair, you deliver to exactly the same set as the broadcast and have gained nothing.

This loop gets the delivery finished. It does not remove the cause, because next time somebody will again be standing at the catch event too late. There are two ways to deal with that, and both hold up.

The first way: register earlier

The time window does not come from the signal, it comes from the registration arriving too late. Whoever moves it forward may keep the broadcast.

For that the item process gets a parallel gateway directly after the start. One branch goes straight to the catch event and registers, the other reserves the storage bin. A join brings both back together, and only then does the booking happen.

<process id="goods-receipt-item" name="Goods receipt item">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="split"/>

  <parallelGateway id="split"/>
  <sequenceFlow sourceRef="split" targetRef="await-release"/>
  <sequenceFlow sourceRef="split" targetRef="reserve-bin"/>

  <intermediateCatchEvent id="await-release" name="Await release">
    <signalEventDefinition signalRef="release-items"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="await-release" targetRef="join"/>

  <serviceTask id="reserve-bin" name="Reserve bin"
               flowable:type="external-worker"
               flowable:topic="reserve-bin"/>
  <sequenceFlow sourceRef="reserve-bin" targetRef="join"/>

  <parallelGateway id="join"/>
  <sequenceFlow sourceRef="join" targetRef="book-stock"/>

  <serviceTask id="book-stock" name="Book stock"
               flowable:type="external-worker"
               flowable:topic="book-stock"/>
  <sequenceFlow sourceRef="book-stock" targetRef="end"/>

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

The subscription now comes into being in the same transaction as the process start. The throw can no longer miss it, because the collective process starts the items itself and throws only afterwards. If the signal fires while the reservation is still running, the signal branch waits at the join for its slow neighbour.

One condition is hard. The path from the start to the catch event has to stay synchronous. If a flowable:async sits there, the subscription only comes into being once the job executor runs the job, and the window is open again.

The reach stays untouched by this. The broadcast is still global and also hits items of other deliveries, see the section about the global signal.

The second way: one message per item

Instead of one signal to everybody, every item gets its own message. In the model exactly one line changes:

<intermediateCatchEvent id="await-release" name="Await release">
  <messageEventDefinition messageRef="item-released"/>
</intermediateCatchEvent>
<message id="item-released" name="item-released"/>

In the collective process the single throw turns into a loop over the items of the delivery. The sender looks for the waiting instance and delivers:

public void releaseItems(String deliveryId, Map<String, Object> variables) {
    List<Execution> waiting = runtimeService.createExecutionQuery()
            .messageEventSubscriptionName("item-released")
            .processVariableValueEquals("deliveryId", deliveryId)
            .list();

    for (Execution execution : waiting) {
        runtimeService.messageEventReceived("item-released", execution.getId(), variables);
    }
}

The gain does not lie in nobody being able to arrive too late here any more. They can. The gain is that it becomes visible and can be fixed.

The sender knows the number of items of this delivery. If it finds fewer waiting instances than expected, it knows that immediately and can log it, try again later or raise an error. With the signal it never had this information, because a throw without receivers looks exactly like a throw to forty.

Why a message does not buffer either

A widespread misunderstanding likes to show up as a comment under texts like this, so let us head it off.

A message is buffered just as little in Flowable as a signal is. If no matching subscription exists at the moment of delivery, the message is gone as well. So it is not about durability.

It is about the addressing, and everything else follows from that. Because the sender means a particular execution, it can check whether it exists. If it does not find it, it has a finding instead of a guess, and because the delivery goes to a particular instance, it can be repeated safely.

The repeating is the caller’s job, not the engine’s. Flowable delivers no automatic follow-up for a failed delivery. Whoever wants one builds it: out of a queue with another delivery attempt, out of a job that collects open items, or out of the timer from the next section.

There are engines that buffer messages with a time to live and thereby close the time window on their own. Whoever is on one of those does not have this problem in this form. That changes nothing about the behaviour of signals, because nobody buffers those there either.

When there are very many receivers

With forty items the loop does not stand out. With a few thousand it does, and then the broadcast carries weight, because one throw replaces a thousand individual calls.

The gap is not quite that large. The broadcast delivers one by one as well, the engine reads the subscriptions and works through them. What is saved is not the delivery itself, but resolving the receivers on the caller’s side and the route there through the API.

The loop gets expensive above all where every delivery gets its own transaction. If the call runs outside an existing transaction, Flowable opens a new one per command, and then you pay a thousand commits instead of a few. Two adjustments keep that in check. The loop belongs in one transaction per batch and not in one per delivery, and an asynchronous continuation behind the catch event moves the follow-up work onto the job executor, distributed and in parallel.

Even so the broadcast stays ahead with large volumes, one command and one query of the subscriptions instead of a thousand. With the early registration from the first way it is safe against the time window as well, while the reach stays the topic of the section about the global signal. Whoever picks the message pays with throughput and gets the finding about which receivers were missing in return.

Where the volume gets truly large, the modelling is worth a look on top of that. A thousand separate process instances cost regardless of whether a signal or a message wakes them. A multi-instance subprocess turns a thousand deliveries into one, provided the waiting step moves up to the process level and the items only continue after that. In return you take on one shared life cycle for all items.

The timer as a second safeguard

Regardless of whether signal or message, one question stays open: what happens if the release does not come at all? A process that waits without a deadline waits forever, and in monitoring that looks like one that is still working.

A timer at the waiting step turns that into a visible state. There is one restriction that is easy to overlook: a boundary event needs an activity it is attached to. A catch event is not one. If you put the attachedToRef on the waiting event anyway, Flowable silently discards the link. The model deploys, the timer shows up in the diagram, and it never fires.

The waiting step therefore moves into an embedded subprocess, and the timer hangs on its border:

<subProcess id="wait-with-deadline" name="Wait for release">
  <startEvent id="wait-start"/>
  <sequenceFlow sourceRef="wait-start" targetRef="await-release"/>
  <intermediateCatchEvent id="await-release" name="Await release">
    <messageEventDefinition messageRef="item-released"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="await-release" targetRef="wait-end"/>
  <endEvent id="wait-end"/>
</subProcess>

<boundaryEvent id="release-overdue" attachedToRef="wait-with-deadline"
               cancelActivity="false">
  <timerEventDefinition>
    <timeDuration>PT30M</timeDuration>
  </timerEventDefinition>
</boundaryEvent>

With cancelActivity="false" the subprocess keeps running, the timer only branches off. There you hang what is supposed to happen in this case: a task for the goods receipt team, a notification to monitoring, or another delivery attempt. The subscription of the execution inside the subprocess stays reachable for the query and for the delivery, so nothing changes about either of the two ways.

The timer does not fix the cause, the time window stays exactly as wide as before. It only turns silent waiting into a case somebody sees. That is little and still the difference between a mistake that gets noticed on the same day and one the supplier reports.

The trap in the error handling

There is a second, entirely independent route to the same damage, and it lies outside the process model.

In landscapes like this, messages rarely arrive directly, they come through a queue or a topic. The receiver takes the message, delivers it to the process instance and acknowledges it afterwards.

What a failure looks like depends on the route, and that gets mixed up easily. If you deliver straight to an execution id that no longer exists, the engine throws an error. If instead you first look for the waiting instances and deliver after that, as in the second way above, nothing flies at all: the list is simply empty, the loop does not run, and the code looks as if everything went fine.

The usual mistake in the error handling looks like this:

try {
    processService.releaseItems(deliveryId, variables);
} catch (FlowableException e) {
    log.info("Release already happened, message is acknowledged");
    acknowledge(message);
}

Two different cases are treated the same here: the release had already happened, or the delivery hit nobody. The first case is harmless and the acknowledgement correct. In the second case a message is acknowledged that had no effect, and afterwards it is gone.

The clean way is to separate the cases before acknowledging:

List<Execution> waiting = findWaitingItems(deliveryId);
if (waiting.isEmpty() && allAlreadyBooked(deliveryId)) {
    acknowledge(message);
    return;
}
if (waiting.isEmpty()) {
    throw new IllegalStateException("No waiting item for delivery " + deliveryId);
}

The rule behind it is more general than BPMN: never acknowledge a message whose effect you have not checked. Error handling that cannot tell success and failure apart turns a loud error into a quiet one.

The global signal hits too much

For completeness, the other damage the same broadcast can do, and which did not occur in the opening story only because there was never more than one delivery running at a time.

A signal is global by default. It reaches every waiting instance with a matching name, across process definitions and instances. If two deliveries arrive at the same time, the release of one also releases the items of the other, without it being noticed anywhere. Goods are booked that have not been released at all.

For the case where a signal really is supposed to work only inside one process instance, Flowable knows an attribute:

<signal id="release-items" name="release-items"
        flowable:scope="processInstance"/>

That does not solve our problem, though, because the collective process and the items are separate process instances. So the broadcast would have to cross the instance boundary and at the same time be limited to one delivery, and the construct does not offer exactly that combination. This too is an argument for the addressed message.

Signal, message or timer?

Signal Message Timer
Addressing broadcast to all waiting parties exactly one execution none, works on the spot
Reach global, optionally limitable to the instance the addressed instance its own instance
Sender learns about the failure no yes not applicable
Repeatable only as another broadcast to all yes, targeted not applicable
Buffered no no not applicable
Effort with many receivers one throw one delivery per instance not applicable
Fits for abort, escalation, “everybody stop”, releases to many with early registration releases, responses, handovers deadlines, escalation after time

Two rows decide, and they point in different directions. Whether the sender learns about the failure decides whether a missed moment becomes a case or a riddle. The effort with many receivers pulls the other way. Neither row touches the time window, that one is closed by early registration alone.

The complete files

The item process in the second way, with a message instead of a signal and with a timer. Whoever goes the first way puts the signal back in here and keeps the parallel gateway from the section above:

<process id="goods-receipt-item" name="Goods receipt item">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="reserve-bin"/>

  <serviceTask id="reserve-bin" name="Reserve bin"
               flowable:type="external-worker"
               flowable:topic="reserve-bin"/>
  <sequenceFlow sourceRef="reserve-bin" targetRef="wait-with-deadline"/>

  <subProcess id="wait-with-deadline" name="Wait for release">
    <startEvent id="wait-start"/>
    <sequenceFlow sourceRef="wait-start" targetRef="await-release"/>
    <intermediateCatchEvent id="await-release" name="Await release">
      <messageEventDefinition messageRef="item-released"/>
    </intermediateCatchEvent>
    <sequenceFlow sourceRef="await-release" targetRef="wait-end"/>
    <endEvent id="wait-end"/>
  </subProcess>
  <sequenceFlow sourceRef="wait-with-deadline" targetRef="book-stock"/>

  <boundaryEvent id="release-overdue" attachedToRef="wait-with-deadline"
                 cancelActivity="false">
    <timerEventDefinition>
      <timeDuration>PT30M</timeDuration>
    </timerEventDefinition>
  </boundaryEvent>
  <sequenceFlow sourceRef="release-overdue" targetRef="chase-release"/>

  <serviceTask id="chase-release" name="Chase release"
               flowable:type="external-worker"
               flowable:topic="chase-release"/>
  <sequenceFlow sourceRef="chase-release" targetRef="end-chase"/>
  <endEvent id="end-chase"/>

  <serviceTask id="book-stock" name="Book stock"
               flowable:type="external-worker"
               flowable:topic="book-stock"/>
  <sequenceFlow sourceRef="book-stock" targetRef="end"/>

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

The delivery, with the check that was missing before:

@Service
public class ReleaseService {

    private final RuntimeService runtimeService;

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

    public int releaseItems(String deliveryId, Map<String, Object> variables) {
        List<Execution> waiting = runtimeService.createExecutionQuery()
                .messageEventSubscriptionName("item-released")
                .processVariableValueEquals("deliveryId", deliveryId)
                .list();

        for (Execution execution : waiting) {
            runtimeService.messageEventReceived(
                    "item-released", execution.getId(), variables);
        }
        return waiting.size();
    }
}

Returning the count is the whole difference to the signal. The caller compares it with the number of open items and knows whether it is done.

Common pitfalls

  • The signal name is built from a variable and the variable is empty when the subscription is created. The instance then waits under a name nobody throws. A look at the subscriptions shows it, the model does not.
  • The repeated throw does not carry the variables along. The instance moves on and fails at the next step. Silent waiting turns into an incident, which is better, but not the goal.
  • A broadcast meant as a repair hits foreign instances. With several concurrent cases, targeted delivery per execution is the only safe variant.
  • The timer is taken for the solution. It makes the problem visible and does not close the time window.
  • The error handling acknowledges a message whose delivery ran into nothing. That is the second, independent route to exactly the same damage.
  • An asynchronous step is inserted before the waiting step, long after the model came into being. The time window then appears after the fact, without anybody having changed anything about the signal.
  • The parallel branch to the catch event gets a flowable:async. The subscription then only comes into being once the job executor runs, and the early registration is gone again.

When a signal is the right choice anyway

After this collection you might think the construct should be abolished. That is not so, it is just built for something else.

A signal fits when the group of receivers is open and is supposed to stay open. “Abort all running inspections of this supplier” is exactly that: the sender does not know how many there are and does not want to know either. If an instance arrives too late, that is no damage, because it would have had nothing left to do anyway.

It also fits when the signal is a state and not a starting shot. A process that reports an operating mode can do so by broadcast, as long as every instance can additionally query the mode instead of relying on the moment alone.

And it fits when many receivers need the same release and the registration is in place early. Then the broadcast plays to its strength, one call instead of a thousand, without the time window as the price.

The line runs where the broadcast turns into a handover somebody has to follow up on. As soon as it is supposed to be noticeable that a particular instance did not react, only the addressed message delivers that finding.

FAQ

Does Flowable buffer thrown signals?
No. A signal reaches only subscriptions that exist at the moment of the throw. There is no place for later receivers and no follow-up.

Does an asynchronous throw solve the problem?
No. An asynchronously executed throw also determines the receivers at the point in time at which it actually runs. The delay shifts the time window, it does not close it.

Are messages buffered then?
In Flowable they are not. The difference lies in the addressing: the sender means a particular instance, therefore notices when it is missing, and can repeat in a targeted way.

Do I have to switch to a message because of the time window?
No. A parallel branch that goes to the catch event directly after the start registers the instance before anything can be thrown at all. The signal stays where it is. You need the message when the sender has to know whether the delivery arrived.

Does the delivery per item not get too slow with many receivers?
It costs more than one throw, but less than it looks, because the broadcast delivers one by one as well. What counts is the transaction boundary and the follow-up work behind the catch event. With very large volumes the broadcast stays ahead, and with early registration the time window is closed too.

How do I find out whether it was the timing?
Through the event subscriptions. If the expected name is there and the instance is still waiting, it was the timing. If a different name or no name at all is there, the subscription was wrong from the start.

Can I limit a signal to one process instance?
Yes, through flowable:scope="processInstance" on the signal declaration. That only helps inside one instance, though. For related but separate process instances the way leads through the message.

Is a boundary timer enough as a safeguard?
It makes the waiting visible and is therefore almost always sensible. It does not remove the cause, the time window stays.

What about multi-instance instead of separate processes per item?
With that the question disappears, because there are no separate instances left that could miss something. In return you take on a model in which all items have the same life cycle and an error in one item touches the others. That is a trade-off and not a solution to the signal problem.

Takeaway

A signal is a bet that all receivers are registered in time. As long as nothing lies in between, you win it every time. As soon as something asynchronous sits before the waiting step, and in a distributed landscape something sits there sooner or later, you lose it at some point, and the loss is invisible.

The bet turns into a promise as soon as the registration no longer depends on chance. A parallel branch that goes straight to the catch event handles that in the model and leaves the broadcast where it is. That is the cheaper way, especially with many receivers.

Whoever additionally has to know whether the delivery arrived switches to the addressed message and pays for it with throughput. That turns a missed moment into a case somebody can work on.

The next sensible step is small: search your models for the signal catch events and look at what sits immediately before them. If it is an asynchronous step, an external worker or a call to the outside, then you already have this time window, and the only open question is when it will strike for the first time.

Sources

All models, Java examples and the scenario of the tool online shop are our own and are written against Flowable 8. The BPMN XML excerpts are cut down to the elements under discussion, namespaces and diagram information are therefore missing.

$ lang DE EN ES