Zum Inhalt springen
Prozessautomatisierung

BPMN-Signal oder Nachricht: warum ein Rundruf Prozesse für immer warten lässt

Ein Signal in BPMN ist ein Rundruf. Es geht an alle, die in diesem Moment darauf warten, und an sonst niemanden. Wer eine Sekunde zu spät ankommt, bekommt nichts, und er erfährt es auch nicht. Das ist keine Schwäche einer bestimmten Engine, sondern die Definition des Konstrukts, und genau deshalb baut man sich damit zuverlässig einen Prozess, der still stehen bleibt.

Dieser Beitrag ist ein vollständiges Tutorial und setzt keine BPMN-Kenntnisse voraus. Es baut einen Wareneingang im Onlinehandel Schritt für Schritt auf, führt den Fehler absichtlich herbei, zeigt ihn im Test, repariert die hängenden Instanzen von Hand und stellt am Ende zwei Wege gegenüber, die das Zeitfenster dauerhaft schließen. Alle Beispiele sind gegen Flowable 8 geschrieben.

Inhalt

Die Lieferung, die im Wareneingang liegen bleibt

Ein Onlineshop verkauft Werkzeug: Bohrmaschinen, Stichsägen, Akkuschrauber, dazu Zubehör und Ersatzteile. Der Nachschub kommt palettenweise, eine Lieferung umfasst vierzig Positionen, und jede Position ist eine Artikelnummer mit einer Menge.

Der Wareneingang läuft als Prozess. Für die Lieferung als Ganzes gibt es einen Sammelprozess, für jede Position einen eigenen. Der Grund ist der übliche: Positionen werden unterschiedlich schnell fertig, eine kann in die Qualitätsprüfung abbiegen, eine andere ist schon eingebucht, und niemand will vierzig Zweige in einem einzigen Diagramm pflegen.

Der Ablauf ist einfach. Der Sammelprozess startet je Position einen Prozess. Jede Position sucht sich einen Lagerplatz, das dauert, weil ein externer Dienst den freien Platz vergibt. Danach warten alle Positionen, bis der Lieferschein geprüft und die Lieferung freigegeben ist. Erst dann darf gebucht werden, denn vorher steht die Ware zwar im Regal, gehört aber noch nicht dem Shop.

Die Freigabe kommt vom Sammelprozess, und weil sie alle Positionen gleichzeitig betrifft, war ein Signal die naheliegende Wahl. Ein Wurf, vierzig Empfänger.

An einem Dienstag stehen dann vierzehn Positionen still. Kein Fehler, kein Incident. Der Sammelprozess ist längst weiter, die Lieferung gilt als freigegeben, und trotzdem ist ein Teil der Ware nie eingebucht worden. Aufgefallen ist es, weil der Lieferant nachgefragt hat, warum die Wareneingangsmeldung ausbleibt.

Der Rest dieses Artikels baut genau diesen Wareneingang auf, bringt ihn zum Stehen und dann wieder zum Laufen.

Was ein Signal ist

Ein Signal ist ein Rundruf. Es hat einen Namen, sonst nichts. Wer es wirft, adressiert niemanden. Wer es fängt, hat sich vorher unter diesem Namen angemeldet.

Diese Anmeldung heißt Subscription und ist der springende Punkt. Die Engine führt eine Tabelle darüber, wer gerade auf welchen Namen wartet, in Flowable ist das ACT_RU_EVENT_SUBSCR. Beim Wurf schaut sie in diese Tabelle, stellt an alle zu, die dort stehen, und ist fertig.

Gemerkt wird das Signal dabei nicht. Es gibt keine Ablage, in der ein geworfenes Signal auf spätere Interessenten wartet. Wer sich eine Sekunde nach dem Wurf anmeldet, findet nichts vor, und die Engine hat auch keine Möglichkeit, ihn darüber zu informieren, denn zum Zeitpunkt des Wurfs existierte er nicht.

Ein Signal überträgt also nur an die Empfänger, die im Moment des Wurfs bereits warten. Alles andere in diesem Text ist eine Folge davon.

Eine Nachricht ist das Gegenstück. Sie ist adressiert, sie geht an genau eine wartende Instanz, und der Absender muss angeben, welche gemeint ist. Das klingt nach mehr Arbeit und ist genau dort ihr Vorteil, wo jemand wissen muss, ob die Zustellung angekommen ist.

Wichtig für alles Weitere ist aber nicht die Wahl zwischen beiden, sondern der Zeitpunkt der Anmeldung. Wer rechtzeitig angemeldet ist, verpasst auch ein Signal nicht.

Was du brauchst

Für dieses Tutorial brauchst du ein JDK, Maven oder Gradle und ein Spring-Boot-Projekt mit dem Flowable-Starter. Eine Datenbank ist nicht nötig, die eingebettete reicht, und für die Tests ist sie ohnehin die einfachste Wahl.

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

Die Prozessmodelle liegen als BPMN-XML unter src/main/resources/processes/, von dort deployt Flowable sie beim Start selbst. Zum Modellieren reicht jeder BPMN-Editor, für dieses Tutorial genügt der Texteditor, weil die Modelle klein bleiben und jede Zeile erklärt wird.

Alles hier gilt für Flowable 8. Beachte, dass diese Linie Spring Boot 4 und Spring Framework 7 voraussetzt. Wer noch auf Spring Boot 3 unterwegs ist, bleibt bei der 7er-Linie von Flowable, an den Modellen und an der hier gezeigten API ändert das nichts. Die Konstrukte selbst sind BPMN-Standard und verhalten sich in anderen Engines gleich, was sich unterscheidet, sind die Namensräume im XML und die Java-API.

Die zwei Prozesse

Der Sammelprozess ist der kürzere. Er legt für jede Position einen Prozess an, wartet auf die Freigabe des Lieferscheins und gibt sie danach weiter.

<process id="wareneingang" name="Wareneingang Lieferung">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="positionen-starten"/>

  <serviceTask id="positionen-starten" name="Positionen starten"
               flowable:type="external-worker"
               flowable:topic="positionen-starten"/>
  <sequenceFlow sourceRef="positionen-starten" targetRef="warte-freigabe"/>

  <intermediateCatchEvent id="warte-freigabe" name="Lieferschein freigegeben">
    <messageEventDefinition messageRef="lieferschein-freigegeben"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="warte-freigabe" targetRef="freigabe-werfen"/>

  <intermediateThrowEvent id="freigabe-werfen" name="Positionen freigeben">
    <signalEventDefinition signalRef="positionen-freigeben"/>
  </intermediateThrowEvent>
  <sequenceFlow sourceRef="freigabe-werfen" targetRef="ende"/>

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

Der Positionsprozess sucht einen Lagerplatz, wartet auf die Freigabe und bucht.

<process id="wareneingang-position" name="Wareneingang Position">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="lagerplatz-reservieren"/>

  <serviceTask id="lagerplatz-reservieren" name="Lagerplatz reservieren"
               flowable:type="external-worker"
               flowable:topic="lagerplatz-reservieren"/>
  <sequenceFlow sourceRef="lagerplatz-reservieren" targetRef="warte-freigabe"/>

  <intermediateCatchEvent id="warte-freigabe" name="Freigabe abwarten">
    <signalEventDefinition signalRef="positionen-freigeben"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="warte-freigabe" targetRef="buchen"/>

  <serviceTask id="buchen" name="Bestand buchen"
               flowable:type="external-worker"
               flowable:topic="bestand-buchen"/>
  <sequenceFlow sourceRef="buchen" targetRef="ende"/>

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

Signal und Nachricht werden einmal auf oberster Ebene deklariert, außerhalb der Prozesse, und dann per signalRef beziehungsweise messageRef referenziert:

<signal id="positionen-freigeben" name="positionen-freigeben"/>
<message id="lieferschein-freigegeben" name="lieferschein-freigegeben"/>

Zwei Details lohnen einen zweiten Blick. Das Signal trägt keinen Bezug zu einer bestimmten Lieferung, es heißt einfach positionen-freigeben. Darauf kommt der Abschnitt über das globale Signal zurück. Und der Schritt vor dem Warten ist ein External Worker Task, also kein Code in der Engine, sondern eine Aufgabe, die ein separater Prozess abholt. Genau dieser Schritt baut das Zeitfenster auf.

Eine Voraussetzung steckt im ersten Schritt und wird später gebraucht: Der Worker hinter positionen-starten gibt jeder Position beim Start die lieferungId als Prozessvariable mit. Ohne sie lässt sich später nicht unterscheiden, welche wartende Instanz zu welcher Lieferung gehört, und genau diese Unterscheidung tragen die Reparatur und der zweite Weg.

Der Wurf und der Fang

Ein Signal wirft man entweder im Modell, wie oben mit dem intermediateThrowEvent, oder von außen über die API:

runtimeService.signalEventReceived("positionen-freigeben");

Mit Variablen, die bei allen Empfängern ankommen:

Map<String, Object> variablen = Map.of("freigegebenAm", LocalDate.now().toString());
runtimeService.signalEventReceived("positionen-freigeben", variablen);

Und, das ist für die Reparatur später wichtig, gezielt an eine einzelne wartende Execution:

runtimeService.signalEventReceived("positionen-freigeben", executionId);

Der Fang passiert im Modell und nur dort. Eine Instanz fängt ein Signal, indem sie an einem Catch-Event steht. Sie kann sich nicht vorsorglich anmelden, sie kann nicht nachfragen, ob sie etwas verpasst hat, und sie merkt nicht, dass gerade geworfen wurde.

Der Positionsprozess ist damit auf Gedeih und Verderb darauf angewiesen, rechtzeitig an warte-freigabe zu stehen.

Wo die Subscription wirklich entsteht

Die Subscription entsteht nicht, wenn der Prozess startet, und auch nicht, wenn er das Catch-Event irgendwann erreichen wird. Sie entsteht, wenn die Ausführung dort ankommt und die Transaktion committet ist. Vorher steht in ACT_RU_EVENT_SUBSCR nichts, und für die Engine existiert dieser Wartende nicht.

Davor liegt in unserem Modell lagerplatz-reservieren. Ein External Worker Task heißt: Die Engine legt eine Aufgabe an und wartet. Ein externer Prozess fragt in einem Intervall nach offenen Aufgaben, holt sich eine, arbeitet sie ab und meldet das Ergebnis zurück. Zwischen dem Anlegen und der Rückmeldung liegen Polling-Intervall, Netz, die Arbeit selbst und ein weiterer Commit.

Für vierzig Positionen heißt das: Sie kommen nicht gemeinsam am Catch-Event an, sondern über Sekunden verteilt. Wer schnell einen Lagerplatz bekommt, steht früh da. Wer auf einen Platz in einem vollen Regal wartet, steht spät da.

Der Sammelprozess weiß davon nichts. Er wartet nur auf die Freigabe des Lieferscheins, und die kommt, wann sie kommt. Fällt sie in dieses Fenster, trifft der Wurf einen Teil der Positionen und den Rest nicht.

Das ist der ganze Fehler. Er braucht keinen Bug und keinen Ausfall, nur zwei Dinge, die unabhängig voneinander schnell oder langsam sein dürfen.

Den Fehler im Test sichtbar machen

Ein Fehler, den man nicht reproduzieren kann, wird beim nächsten Mal genauso überraschen. Der Test dazu ist kurz, weil das Zeitfenster sich im Test einfach herstellen lässt: Eine Position lässt man das Catch-Event erreichen, die andere nicht.

@Test
void shouldLoseSignalWhenPositionIsStillInWorkerTask() {
    ProcessInstance schnell = runtimeService.startProcessInstanceByKey("wareneingang-position");
    ProcessInstance langsam = runtimeService.startProcessInstanceByKey("wareneingang-position");

    erledigeLagerplatz(schnell);

    runtimeService.signalEventReceived("positionen-freigeben");

    erledigeLagerplatz(langsam);

    assertThat(aktiveAktivitaet(schnell)).isEqualTo("buchen");
    assertThat(aktiveAktivitaet(langsam)).isEqualTo("warte-freigabe");
}

Die zweite Zusicherung ist die eigentliche Aussage: Die langsame Position steht auf warte-freigabe und bleibt dort, dauerhaft. Es gibt keinen zweiten Wurf, keinen Retry und keine Wiedervorlage, weil die Engine gar nicht weiß, dass hier jemand etwas verpasst hat.

Zwei Helfer sind hier abgekürzt. erledigeLagerplatz schließt den External-Worker-Job der übergebenen Instanz ab, das läuft über den ManagementService mit createExternalWorkerJobAcquireBuilder, dem Abschluss-Builder und der Zuordnung des Jobs über seine Prozessinstanz-Id. Diese Zuordnung ist der Teil, den man nicht weglassen darf, sonst erledigt der Test bei zwei offenen Jobs womöglich die falsche Position. aktiveAktivitaet liest die aktive Aktivität der Instanz aus.

Ein Test wie dieser gehört in die Testsuite und nicht in ein Notizbuch. Er beschreibt das Verhalten, das man ändern will, und er wird rot, sobald die Änderung greift. Dann schreibt man ihn um, statt ihn zu löschen.

Die Diagnose: wer wartet, und worauf

Auf einer laufenden Umgebung ist die erste Frage: Wer steht überhaupt an dem Catch-Event, und unter welchem Namen ist er angemeldet?

List<Execution> wartende = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("positionen-freigeben")
        .list();

Zwei Fälle sind zu unterscheiden, und sie führen zu ganz verschiedenen Reparaturen.

Steht der erwartete Name da und die Liste ist nicht leer, war es das Timing. Die Instanzen sind korrekt angemeldet, sie haben den Wurf nur verpasst. Ein erneuter Wurf hilft.

Ist die Liste leer, obwohl Instanzen auf dem Event stehen, stimmt der Name nicht. Das passiert, wenn der Signalname aus einer Variable gebildet wird und die Variable beim Anlegen der Subscription leer war. Dann hilft kein Wurf, weil niemand unter dem Namen zuhört, den du wirfst. Diesen Fall erkennt man nur, wenn man ihn kennt, und er ist der unangenehmere.

Dazu die fachliche Gegenprobe, die nichts mit der Engine zu tun hat: Wie viele Positionen dieser Lieferung sind gebucht, und wie viele nicht? Stimmt die Zahl der hängenden Instanzen mit der Zahl der fehlenden Buchungen überein, ist die Diagnose rund.

Die Sofort-Reparatur

Auf einer Produktionsumgebung will man zuerst die vierzehn Positionen weiterlaufen lassen und danach in Ruhe die Ursache beheben. Der Wurf lässt sich nachholen:

runtimeService.signalEventReceived("positionen-freigeben", variablen);

Dabei geht es um die Variablen und um die Reichweite, und beides hat schon Schaden angerichtet.

Erstens die Variablen. Wenn der Wurf im Modell Daten mitgibt und ein nachfolgender Schritt sie liest, muss der nachgeholte Wurf dieselben Daten tragen. Fehlen sie, läuft die Position zwar weiter, scheitert aber am nächsten Task, und aus einer wartenden Instanz wird ein Incident. Das ist eine Verbesserung, aber keine Reparatur.

Zweitens die Reichweite. Der Wurf ohne Angabe einer Execution geht an alle, die gerade warten, also möglicherweise auch an Positionen einer ganz anderen Lieferung, die gerade völlig zu Recht auf ihre eigene Freigabe warten. Wer das nicht will, grenzt die Menge zuerst auf die eigene Lieferung ein und stellt dann einzeln zu:

List<Execution> wartende = runtimeService.createExecutionQuery()
        .signalEventSubscriptionName("positionen-freigeben")
        .processVariableValueEquals("lieferungId", lieferungId)
        .list();

for (Execution execution : wartende) {
    runtimeService.signalEventReceived("positionen-freigeben", execution.getId(), variablen);
}

Der Filter auf die Prozessvariable ist dabei kein Detail. Die Abfrage aus dem Diagnose-Abschnitt hatte ihn nicht, sie sollte ja alle Wartenden zeigen. Wer sie unverändert für die Reparatur wiederverwendet, stellt an genau dieselbe Menge zu wie der Rundruf und hat nichts gewonnen.

Diese Schleife bringt die Lieferung zu Ende. Sie beseitigt die Ursache nicht, denn beim nächsten Mal steht wieder jemand zu spät am Catch-Event. Dafür gibt es zwei Wege, und beide sind tragfähig.

Der erste Weg: die Anmeldung vorziehen

Das Zeitfenster entsteht nicht durch das Signal, sondern dadurch, dass die Anmeldung zu spät kommt. Wer sie vorzieht, darf den Rundruf behalten.

Dafür bekommt der Positionsprozess direkt nach dem Start ein paralleles Gateway. Ein Zweig geht sofort auf das Catch-Event und meldet sich an, der andere reserviert den Lagerplatz. Ein Join führt beide wieder zusammen, gebucht wird erst danach.

<process id="wareneingang-position" name="Wareneingang Position">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="aufteilen"/>

  <parallelGateway id="aufteilen"/>
  <sequenceFlow sourceRef="aufteilen" targetRef="warte-freigabe"/>
  <sequenceFlow sourceRef="aufteilen" targetRef="lagerplatz-reservieren"/>

  <intermediateCatchEvent id="warte-freigabe" name="Freigabe abwarten">
    <signalEventDefinition signalRef="positionen-freigeben"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="warte-freigabe" targetRef="zusammenfuehren"/>

  <serviceTask id="lagerplatz-reservieren" name="Lagerplatz reservieren"
               flowable:type="external-worker"
               flowable:topic="lagerplatz-reservieren"/>
  <sequenceFlow sourceRef="lagerplatz-reservieren" targetRef="zusammenfuehren"/>

  <parallelGateway id="zusammenfuehren"/>
  <sequenceFlow sourceRef="zusammenfuehren" targetRef="buchen"/>

  <serviceTask id="buchen" name="Bestand buchen"
               flowable:type="external-worker"
               flowable:topic="bestand-buchen"/>
  <sequenceFlow sourceRef="buchen" targetRef="ende"/>

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

Die Subscription entsteht damit in derselben Transaktion wie der Prozessstart. Der Wurf kann sie nicht mehr verfehlen, denn der Sammelprozess startet die Positionen selbst und wirft erst danach. Feuert das Signal, während die Reservierung noch läuft, wartet der Signal-Zweig am Join auf den langsamen Nachbarn.

Eine Bedingung ist hart. Der Weg vom Start bis zum Catch-Event muss synchron bleiben. Steht dort ein flowable:async, entsteht die Subscription erst, wenn der Job-Executor den Job ausführt, und das Fenster ist wieder offen.

Die Reichweite bleibt davon unberührt. Der Rundruf ist weiterhin global und trifft auch Positionen anderer Lieferungen, dazu der Abschnitt über das globale Signal.

Der zweite Weg: eine Nachricht je Position

Statt eines Signals an alle bekommt jede Position ihre eigene Nachricht. Im Modell ändert sich genau eine Zeile:

<intermediateCatchEvent id="warte-freigabe" name="Freigabe abwarten">
  <messageEventDefinition messageRef="position-freigegeben"/>
</intermediateCatchEvent>
<message id="position-freigegeben" name="position-freigegeben"/>

Im Sammelprozess wird aus dem einen Wurf eine Schleife über die Positionen der Lieferung. Der Absender sucht sich die wartende Instanz und stellt zu:

public void gibPositionenFrei(String lieferungId, Map<String, Object> variablen) {
    List<Execution> wartende = runtimeService.createExecutionQuery()
            .messageEventSubscriptionName("position-freigegeben")
            .processVariableValueEquals("lieferungId", lieferungId)
            .list();

    for (Execution execution : wartende) {
        runtimeService.messageEventReceived("position-freigegeben", execution.getId(), variablen);
    }
}

Der Gewinn liegt nicht darin, dass hier plötzlich niemand mehr zu spät kommen kann. Er kann. Der Gewinn ist, dass es sichtbar wird und sich beheben lässt.

Der Absender kennt die Zahl der Positionen dieser Lieferung. Findet er weniger wartende Instanzen als erwartet, weiß er das sofort und kann es protokollieren, später erneut versuchen oder einen Fehler erzeugen. Beim Signal hatte er diese Information nie, weil ein Wurf ohne Empfänger genauso aussieht wie ein Wurf an vierzig.

Warum eine Nachricht auch nicht puffert

Ein verbreitetes Missverständnis steht gern als Kommentar unter solchen Texten, dem sei hier zuvorgekommen.

Eine Nachricht wird in Flowable genauso wenig gepuffert wie ein Signal. Wenn zum Zeitpunkt der Zustellung keine passende Subscription existiert, ist die Nachricht ebenfalls weg. An der Haltbarkeit liegt es also nicht.

Es liegt an der Adressierung, und daraus folgt alles Weitere. Weil der Absender eine bestimmte Execution meint, kann er prüfen, ob es sie gibt. Findet er sie nicht, hat er einen Befund statt einer Vermutung, und weil die Zustellung an eine bestimmte Instanz geht, lässt sie sich gefahrlos wiederholen.

Das Wiederholen ist dabei Aufgabe des Aufrufers, nicht der Engine. Flowable liefert keine automatische Wiedervorlage für eine gescheiterte Zustellung. Wer sie will, baut sie sich: aus einer Queue mit erneuter Zustellung, aus einem Job, der offene Positionen einsammelt, oder aus dem Timer aus dem nächsten Abschnitt.

Es gibt Engines, die Nachrichten mit einer Lebensdauer zwischenspeichern und das Zeitfenster damit von sich aus schließen. Wer auf einer solchen unterwegs ist, hat dieses Problem nicht in dieser Form. Das ändert am Verhalten von Signalen nichts, denn die puffert auch dort niemand.

Wenn es sehr viele Empfänger sind

Bei vierzig Positionen fällt die Schleife nicht auf. Bei einigen tausend schon, und dann wiegt der Rundruf schwer, denn ein Wurf ersetzt tausend einzelne Aufrufe.

Ganz so groß ist der Abstand nicht. Auch der Rundruf stellt einzeln zu, die Engine liest die Subscriptions und geht sie durch. Gespart wird nicht die Zustellung selbst, sondern das Auflösen der Empfänger auf der Seite des Aufrufers und der Weg dorthin über die API.

Teuer wird die Schleife vor allem dort, wo jede Zustellung ihre eigene Transaktion bekommt. Läuft der Aufruf außerhalb einer bestehenden Transaktion, öffnet Flowable je Kommando eine neue, und dann zahlt man tausend Commits statt einiger weniger. Zwei Stellschrauben halten das im Rahmen. Die Schleife gehört in eine Transaktion je Stapel und nicht in eine je Zustellung, und eine asynchrone Fortsetzung hinter dem Catch-Event verlagert die Folgearbeit auf den Job-Executor, verteilt und parallel.

Trotzdem bleibt der Rundruf bei großen Mengen im Vorteil, ein Kommando und eine Abfrage der Subscriptions statt tausend. Mit der frühen Anmeldung aus dem ersten Weg ist er auch gegen das Zeitfenster sicher, die Reichweite bleibt das Thema des Abschnitts über das globale Signal. Wer sich für die Nachricht entscheidet, bezahlt Durchsatz und bekommt dafür den Befund, welche Empfänger gefehlt haben.

Wo die Menge wirklich groß wird, lohnt zusätzlich der Blick auf die Modellierung. Tausend eigene Prozessinstanzen kosten unabhängig davon, ob ein Signal oder eine Nachricht sie weckt. Ein Multi-Instance-Subprozess macht aus tausend Zustellungen eine, wenn der Warteschritt dabei auf die Prozessebene wandert und die Positionen erst danach weiterlaufen. Dafür handelt man sich einen gemeinsamen Lebenslauf für alle Positionen ein.

Der Timer als zweite Sicherung

Unabhängig davon, ob Signal oder Nachricht, bleibt eine Frage offen: Was passiert, wenn die Freigabe gar nicht kommt? Ein Prozess, der ohne Frist wartet, wartet für immer, und das sieht im Monitoring aus wie einer, der noch arbeitet.

Ein Timer am Warteschritt macht daraus einen sichtbaren Zustand. Dabei gibt es eine Einschränkung, die man leicht übersieht: Ein Boundary-Event braucht eine Aktivität, an der es hängt. Ein Catch-Event ist keine. Schreibst du das attachedToRef trotzdem auf das Warte-Event, verwirft Flowable die Verknüpfung stillschweigend. Das Modell deployt, der Timer steht im Diagramm, und er feuert nie.

Der Warteschritt wandert deshalb in einen eingebetteten Subprozess, und der Timer hängt an dessen Rand:

<subProcess id="warten-mit-frist" name="Auf Freigabe warten">
  <startEvent id="warten-start"/>
  <sequenceFlow sourceRef="warten-start" targetRef="warte-freigabe"/>
  <intermediateCatchEvent id="warte-freigabe" name="Freigabe abwarten">
    <messageEventDefinition messageRef="position-freigegeben"/>
  </intermediateCatchEvent>
  <sequenceFlow sourceRef="warte-freigabe" targetRef="warten-ende"/>
  <endEvent id="warten-ende"/>
</subProcess>

<boundaryEvent id="freigabe-ueberfaellig" attachedToRef="warten-mit-frist"
               cancelActivity="false">
  <timerEventDefinition>
    <timeDuration>PT30M</timeDuration>
  </timerEventDefinition>
</boundaryEvent>

Mit cancelActivity="false" läuft der Subprozess weiter, der Timer zweigt nur ab. Dorthin hängt man das, was in diesem Fall passieren soll: eine Aufgabe für den Wareneingang, eine Meldung an das Monitoring, oder ein erneuter Zustellversuch. Die Subscription der Execution im Subprozess bleibt für die Abfrage und für die Zustellung erreichbar, an den beiden Wegen ändert sich also nichts.

Der Timer behebt die Ursache dabei nicht, das Zeitfenster bleibt genau so groß wie vorher. Er verwandelt nur stilles Warten in einen Vorgang, den jemand sieht. Das ist wenig und trotzdem der Unterschied zwischen einem Fehler, der am selben Tag auffällt, und einem, den der Lieferant meldet.

Die Falle in der Fehlerbehandlung

Es gibt einen zweiten, ganz eigenständigen Weg zu demselben Schaden, und er liegt außerhalb des Prozessmodells.

Nachrichten kommen in solchen Landschaften selten direkt an, sondern über eine Queue oder ein Topic. Der Empfänger nimmt die Nachricht entgegen, stellt sie an die Prozessinstanz zu und bestätigt sie danach.

Wie ein Fehlschlag dabei aussieht, hängt vom Weg ab, und das wird gern verwechselt. Stellst du direkt an eine Execution-ID zu, die es nicht mehr gibt, wirft die Engine einen Fehler. Suchst du dagegen erst die wartenden Instanzen und stellst dann zu, wie im zweiten Weg oben, fliegt gar nichts: Die Liste ist einfach leer, die Schleife läuft nicht, und der Code sieht aus, als sei alles gut gegangen.

Der übliche Fehler in der Fehlerbehandlung sieht so aus:

try {
    prozessService.gibPositionenFrei(lieferungId, variablen);
} catch (FlowableException e) {
    log.info("Freigabe bereits erfolgt, Nachricht wird bestaetigt");
    acknowledge(nachricht);
}

Hier werden zwei verschiedene Fälle gleich behandelt: Die Freigabe war schon erfolgt, oder die Zustellung hat niemanden getroffen. Der erste Fall ist harmlos und die Bestätigung richtig. Im zweiten Fall wird eine Nachricht bestätigt, die nichts bewirkt hat, und sie ist danach weg.

Sauber ist es, die Fälle zu trennen, bevor man bestätigt:

List<Execution> wartende = sucheWartendePositionen(lieferungId);
if (wartende.isEmpty() && alleBereitsGebucht(lieferungId)) {
    acknowledge(nachricht);
    return;
}
if (wartende.isEmpty()) {
    throw new IllegalStateException("Keine wartende Position fuer Lieferung " + lieferungId);
}

Die Regel dahinter ist allgemeiner als BPMN: Bestätige nie eine Nachricht, deren Wirkung du nicht geprüft hast. Eine Fehlerbehandlung, die Erfolg und Misserfolg nicht unterscheiden kann, verwandelt einen lauten Fehler in einen leisen.

Das globale Signal trifft zu viel

Der Vollständigkeit halber der andere Schaden, den derselbe Rundruf anrichten kann, und der in der Eingangsgeschichte nur deshalb nicht auftrat, weil immer nur eine Lieferung gleichzeitig lief.

Ein Signal ist standardmäßig global. Es erreicht jede wartende Instanz mit passendem Namen, über Prozessdefinitionen und Instanzen hinweg. Kommen zwei Lieferungen gleichzeitig an, gibt die Freigabe der einen auch die Positionen der anderen frei, und zwar ohne dass es irgendwo auffällt. Ware wird gebucht, die noch gar nicht freigegeben ist.

Für den Fall, dass ein Signal wirklich nur innerhalb einer Prozessinstanz wirken soll, kennt Flowable ein Attribut:

<signal id="positionen-freigeben" name="positionen-freigeben"
        flowable:scope="processInstance"/>

Das löst unser Problem allerdings nicht, denn Sammelprozess und Positionen sind eigene Prozessinstanzen. Der Rundruf müsste also über die Instanzgrenze gehen und gleichzeitig auf eine Lieferung begrenzt sein, und genau diese Kombination gibt das Konstrukt nicht her. Auch das ist ein Argument für die adressierte Nachricht.

Signal, Nachricht oder Timer?

Signal Nachricht Timer
Adressierung Rundruf an alle Wartenden genau eine Execution keine, wirkt an Ort und Stelle
Reichweite global, optional auf die Instanz begrenzbar die adressierte Instanz die eigene Instanz
Absender erfährt vom Fehlschlag nein ja entfällt
Wiederholbar nur als erneuter Rundruf an alle ja, gezielt entfällt
Gepuffert nein nein entfällt
Aufwand bei vielen Empfängern ein Wurf eine Zustellung je Instanz entfällt
Passt für Abbruch, Eskalation, „alle stoppen“, Freigaben an viele bei früher Anmeldung Freigaben, Rückmeldungen, Übergaben Fristen, Eskalation nach Zeit

Zwei Zeilen entscheiden, und sie zeigen in verschiedene Richtungen. Ob der Absender vom Fehlschlag erfährt, entscheidet darüber, ob aus einem verpassten Moment ein Vorgang wird oder ein Rätsel. Der Aufwand bei vielen Empfängern zieht in die andere Richtung. Keine der beiden Zeilen betrifft das Zeitfenster, das schließt allein die frühe Anmeldung.

Die vollständigen Dateien

Der Positionsprozess im zweiten Weg, mit Nachricht statt Signal und mit Timer. Wer den ersten Weg geht, setzt hier wieder das Signal ein und behält das parallele Gateway aus dem Abschnitt oben:

<process id="wareneingang-position" name="Wareneingang Position">
  <startEvent id="start"/>
  <sequenceFlow sourceRef="start" targetRef="lagerplatz-reservieren"/>

  <serviceTask id="lagerplatz-reservieren" name="Lagerplatz reservieren"
               flowable:type="external-worker"
               flowable:topic="lagerplatz-reservieren"/>
  <sequenceFlow sourceRef="lagerplatz-reservieren" targetRef="warten-mit-frist"/>

  <subProcess id="warten-mit-frist" name="Auf Freigabe warten">
    <startEvent id="warten-start"/>
    <sequenceFlow sourceRef="warten-start" targetRef="warte-freigabe"/>
    <intermediateCatchEvent id="warte-freigabe" name="Freigabe abwarten">
      <messageEventDefinition messageRef="position-freigegeben"/>
    </intermediateCatchEvent>
    <sequenceFlow sourceRef="warte-freigabe" targetRef="warten-ende"/>
    <endEvent id="warten-ende"/>
  </subProcess>
  <sequenceFlow sourceRef="warten-mit-frist" targetRef="buchen"/>

  <boundaryEvent id="freigabe-ueberfaellig" attachedToRef="warten-mit-frist"
                 cancelActivity="false">
    <timerEventDefinition>
      <timeDuration>PT30M</timeDuration>
    </timerEventDefinition>
  </boundaryEvent>
  <sequenceFlow sourceRef="freigabe-ueberfaellig" targetRef="freigabe-anmahnen"/>

  <serviceTask id="freigabe-anmahnen" name="Freigabe anmahnen"
               flowable:type="external-worker"
               flowable:topic="freigabe-anmahnen"/>
  <sequenceFlow sourceRef="freigabe-anmahnen" targetRef="ende-mahnung"/>
  <endEvent id="ende-mahnung"/>

  <serviceTask id="buchen" name="Bestand buchen"
               flowable:type="external-worker"
               flowable:topic="bestand-buchen"/>
  <sequenceFlow sourceRef="buchen" targetRef="ende"/>

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

Die Zustellung, mit der Prüfung, die vorher fehlte:

@Service
public class FreigabeService {

    private final RuntimeService runtimeService;

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

    public int gibPositionenFrei(String lieferungId, Map<String, Object> variablen) {
        List<Execution> wartende = runtimeService.createExecutionQuery()
                .messageEventSubscriptionName("position-freigegeben")
                .processVariableValueEquals("lieferungId", lieferungId)
                .list();

        for (Execution execution : wartende) {
            runtimeService.messageEventReceived(
                    "position-freigegeben", execution.getId(), variablen);
        }
        return wartende.size();
    }
}

Die Rückgabe der Anzahl ist der ganze Unterschied zum Signal. Der Aufrufer vergleicht sie mit der Zahl der offenen Positionen und weiß, ob er fertig ist.

Häufige Fallstricke

  • Der Signalname wird aus einer Variable gebildet und die Variable ist beim Anlegen der Subscription leer. Die Instanz wartet dann unter einem Namen, den niemand wirft. Der Blick in die Subscriptions zeigt es, das Modell nicht.
  • Der nachgeholte Wurf trägt die Variablen nicht mit. Die Instanz läuft weiter und scheitert am nächsten Schritt. Aus dem stillen Warten wird ein Incident, das ist besser, aber nicht das Ziel.
  • Ein Rundruf zur Reparatur trifft fremde Instanzen. Bei mehreren gleichzeitigen Vorgängen ist die gezielte Zustellung je Execution die einzige sichere Variante.
  • Der Timer wird für die Lösung gehalten. Er macht das Problem sichtbar und schließt das Zeitfenster nicht.
  • Die Fehlerbehandlung bestätigt eine Nachricht, deren Zustellung ins Leere lief. Das ist der zweite, unabhängige Weg zu genau demselben Schaden.
  • Ein asynchroner Schritt wird vor dem Warteschritt eingefügt, lange nachdem das Modell entstanden ist. Das Zeitfenster entsteht dann nachträglich, ohne dass jemand am Signal etwas geändert hat.
  • Der parallele Zweig zum Catch-Event bekommt ein flowable:async. Damit entsteht die Subscription erst, wenn der Job-Executor läuft, und die frühe Anmeldung ist wieder dahin.

Wann ein Signal trotzdem richtig ist

Nach dieser Sammlung könnte man meinen, das Konstrukt gehöre abgeschafft. Das stimmt nicht, es ist nur für etwas anderes gebaut.

Ein Signal passt, wenn der Empfängerkreis offen ist und offen bleiben soll. „Alle laufenden Prüfungen dieses Lieferanten abbrechen“ ist genau das: Der Absender weiß nicht, wie viele es sind, und will es auch nicht wissen. Kommt eine Instanz zu spät, ist das kein Schaden, denn sie hätte ohnehin nichts mehr zu tun.

Es passt außerdem, wenn das Signal ein Zustand ist und kein Startschuss. Ein Prozess, der einen Betriebsmodus meldet, kann das per Rundruf tun, solange jede Instanz den Modus zusätzlich abfragen kann, statt sich allein auf den Moment zu verlassen.

Und es passt, wenn viele Empfänger dieselbe Freigabe brauchen und die Anmeldung früh steht. Dann spielt der Rundruf seine Stärke aus, ein Aufruf statt tausend, ohne das Zeitfenster als Preis.

Die Grenze verläuft dort, wo aus dem Rundruf eine Übergabe wird, die jemand nachhalten muss. Sobald es auffallen soll, wenn eine bestimmte Instanz nicht reagiert hat, liefert nur die adressierte Nachricht diesen Befund.

FAQ

Puffert Flowable geworfene Signale?
Nein. Ein Signal erreicht ausschließlich Subscriptions, die im Moment des Wurfs bestehen. Es gibt keine Ablage für spätere Empfänger und keine Wiedervorlage.

Löst ein asynchroner Wurf das Problem?
Nein. Auch ein asynchron ausgeführter Wurf bestimmt die Empfänger zu dem Zeitpunkt, an dem er tatsächlich läuft. Die Verzögerung verschiebt das Zeitfenster, sie schließt es nicht.

Werden Nachrichten denn gepuffert?
In Flowable nicht. Der Unterschied liegt in der Adressierung: Der Absender meint eine bestimmte Instanz, merkt daher, wenn sie fehlt, und kann gezielt wiederholen.

Muss ich für das Zeitfenster auf eine Nachricht umstellen?
Nein. Ein paralleler Zweig, der direkt nach dem Start auf das Catch-Event geht, meldet die Instanz an, bevor überhaupt geworfen werden kann. Das Signal bleibt dabei, wo es ist. Die Nachricht braucht man, wenn der Absender wissen muss, ob die Zustellung angekommen ist.

Wird die Zustellung je Position bei vielen Empfängern nicht zu langsam?
Sie kostet mehr als ein Wurf, aber weniger als es aussieht, denn auch der Rundruf stellt einzeln zu. Ins Gewicht fallen die Transaktionsgrenze und die Folgearbeit hinter dem Catch-Event. Bei sehr großen Mengen bleibt der Rundruf im Vorteil, und mit früher Anmeldung ist auch das Zeitfenster zu.

Wie finde ich heraus, ob es am Timing lag?
Über die Event-Subscriptions. Steht der erwartete Name dort und die Instanz wartet trotzdem, war es das Timing. Steht dort ein anderer oder gar kein Name, war die Subscription von Anfang an falsch.

Kann ich ein Signal auf eine Prozessinstanz begrenzen?
Ja, über flowable:scope="processInstance" an der Signal-Deklaration. Das hilft aber nur innerhalb einer Instanz. Für zusammengehörige, aber eigenständige Prozessinstanzen führt der Weg über die Nachricht.

Reicht ein Boundary-Timer als Absicherung?
Er macht das Warten sichtbar und ist deshalb fast immer sinnvoll. Die Ursache beseitigt er nicht, das Zeitfenster bleibt.

Was ist mit Multi-Instance statt eigener Prozesse je Position?
Damit verschwindet die Frage, weil es keine getrennten Instanzen mehr gibt, die etwas verpassen könnten. Man handelt sich dafür ein Modell ein, in dem alle Positionen denselben Lebenslauf haben und ein Fehler in einer Position die anderen berührt. Das ist eine Abwägung und keine Lösung des Signalproblems.

Fazit

Ein Signal ist eine Wette darauf, dass alle Empfänger rechtzeitig angemeldet sind. Solange nichts dazwischen liegt, gewinnt man sie jedes Mal. Sobald vor dem Warteschritt etwas Asynchrones steht, und in einer verteilten Landschaft steht dort früher oder später etwas, verliert man sie irgendwann, und der Verlust ist unsichtbar.

Aus der Wette wird eine Zusage, sobald die Anmeldung nicht mehr vom Zufall abhängt. Ein paralleler Zweig, der sofort auf das Catch-Event geht, erledigt das im Modell und lässt den Rundruf, wo er ist. Das ist der günstigere Weg, gerade bei vielen Empfängern.

Wer zusätzlich wissen muss, ob die Zustellung angekommen ist, wechselt auf die adressierte Nachricht und bezahlt das mit Durchsatz. Damit wird aus einem verpassten Moment ein Vorgang, den jemand bearbeiten kann.

Der nächste sinnvolle Schritt ist klein: Such in deinen Modellen die Signal-Catch-Events und sieh nach, was unmittelbar davor steht. Ist es ein asynchroner Schritt, ein External Worker oder ein Aufruf nach außen, dann hast du dieses Zeitfenster bereits, und die einzige offene Frage ist, wann es das erste Mal zuschlägt.

Quellen

Alle Modelle, Java-Beispiele und das Szenario des Werkzeug-Onlineshops sind eigene und gegen Flowable 8 geschrieben. Die BPMN-XML-Ausschnitte sind auf die besprochenen Elemente gekürzt, Namensräume und Diagramm-Informationen fehlen daher.

$ lang DE EN ES