Archive for definicja procesu

Definicja procesu (5) – obsługa błędów

Dziś poruszymy temat obsługi błędów w procesach. Zacznijmy od tego, że w NGinn każde zadanie, jeśli zostanie uruchomione, może zakończyć się z następującymi rezultatami:

  • zakończenie normalne (prawidłowe, na wyjściu otrzymujemy dane wyjściowe z zadania)
  • anulowanie
  • zakończenie na skutek błędu

Zakończenie normalne to przypadek omawiany już wcześniej – po zakończeniu zadania są produkowane tokeny w miejscach do których prowadzą połączenia wychodzące (oczywiście jeśli mamy split XOR lub OR to są jeszcze sprawdzane warunki dla tych przejść). Po anulowaniu tokeny wyjściowe nie są produkowane (jest taka opcja, ale o tym kiedy indziej). Po zakończeniu z błędem natomiast mamy pewne możliwości zareagowania na ten błąd i odpowiedniego dostosowania dalszego biegu procesu – w tym celu używamy ‘error handlerów’ czyli specjalnych przejść uaktywnianych tylko w  razie błędu:

Obsługa błędu (1)

Obsługa błędu (1)

 

W tym procesie normalny przebieg jest taki: start->T1->T2->end. Natomiast zadanie T1 ma dodatkowe przejście wychodzące, oznaczone na rysunku dwoma czerwonymi ukośnikami. To przejście jest uruchamiane w razie zakończenia zadania T1 z błędem (tzn jeśli T1 się ‘wywali’ to token pojawi się na przejściu T1->T1 error a nie na T1->T2), czyli kolejnym uruchomionym zadaniem będzie ‘T1 error’ – nasz ‘error handler’ dla T1.

No dobra, świetnie: potrafimy już przyczepić obsługę błędów do jakiegoś zadania. Ale przecież w procesie wiele rzeczy może się nie udać, jeśli zaczniemy wszędzie doczepiać obsługę błędów to szybko nasz diagram zamieni się w chaotyczną plątaninę strzałek. A poza tym: co się stanie jeśli zadanie się ‘wywali’ a nie ma zdefiniowanej obsługi błędów? Żeby odpowiedzieć na powyższe wątpliwości zobaczmy kolejny diagram:

 

Obsługa błędów (2)

Obsługa błędów (2)

 

Na tym schemacie mamy zadanie blokowe (to z przerywaną ramką) do którego dołączona jest obsługa błędu (T1 error). Zapis taki oznacza że w przypadku wystąpienia jakiegokolwiek błędu w zadaniu blokowym obsługujemy ten błąd w ‘T1 error’. A dokładnie: jeśli w trakcie wykonania zadania blokowego pojawi się błąd w zadaniu ‘Task1′ lub ‘Task2′ to całe zadanie blokowe zostanie zakończone z błędem. Jeśli były w nim jakieś inne aktywne zadania w momencie wystąpienia błędu to zostaną one anulowane – wszystko co było wewnątrz zadania blokowego zostaje zakończone i dopiero wtedy całe zadanie blokowe kończy się z błędem. Informacje o błędzie są propagowane w górę od zadania które spowodowało błąd – czyli kod błędu w zadaniu blokowym będzie taki sam jak kod błędu zwrócony przez zadanie wewnętrzne które zakończyło się błędem. Mętne? Czy jesteś czytelniku programistą? To pomyśl o konstrukcji try-catch: wszystko co jest wewnątrz zadania blokowego to ‘try’, zaś error handler z czerwonymi ukośnikami to ‘catch’.

A co jeśli zadanie blokowe także nie ma swojej obsługi błędów? Wtedy NGinn propaguje błąd dalej w górę aż dojdzie na poziom zadania blokowego które już ma error handler – czyli ta konstrukcja może być dowolnie głęboko zagnieżdżona. Tylko że do tej pory zakładam że w końcu wyjdziemy na taki poziom gdzie ta obsługa błędu jest. A co gdy nie ma? W końcu idąc w górę dojdziemy na poziom całego procesu – do procesu już nie można sobie doczepić ‘error handlera’ bo nie ma do czego. Czyli wtedy mamy sytuację że nasz ‘wyjątek’ nie jest obsługiwany w ramach procesu  (dla programistów: unhandled exception). W normalnych programach taka sytuacja jest awarią – system wyświetla informację o błędzie i natychmiast kończy działanie programu. A u nas? NGinn mógłby wtedy wpisać do logu że ‘proces XYZ niespodziewanie zakończył się z powodu nieobsłużonego błędu’ i usunąć ten proces, ale to nie jest działanie cywilizowane w świecie procesów biznesowych – pamiętajmy że ten proces reprezentuje jakieś dokumenty, ludzkie działania, decyzje w firmie i nie może tak sobie zniknąć.

Dlatego w NGinn taką sytuacje traktujemy jako typową. To znaczy: normalne jest że definicja procesu nie zawiera obsługi błędów i takie procesy też powinny działać i dawać spodziewane wyniki. A jeśli wystąpi błąd to ma zostać o tym powiadomiony administrator systemu i powinien on mieć szansę ręcznej interwencji w celu usunięcia lub obejścia problemu. Jak to się dzieje? Otóż gdy NGinn w trakcie obsługi błędu stwierdzi że w całym procesie brak jakiejkolwiek obsługi tego błędu to błąd ten nie będzie propagowany w górę. Zamiast tego winowajca (zadanie które ten błąd wygenerowało) jest oznaczane jako ‘FailedActive’ – tzn zakończone z błędem, ale jeszcze nie wiadomo co z nim zrobić. Zadanie takie już się zakończyło, ale dla zadania nadrzędnego jest nadal aktywne i nie generuje żadnych dalszych zdarzeń. Administrator systemu może sobie takie przypadki odszukać (albo nawet zostać automatycznie powiadomiony) i wtedy może przeprowadzić następujące działania:

  • ponownie uruchomić zadanie (np gdy błąd to tylko przejściowy problem) 
  • potwierdzić że to zadanie to rzeczywiście błąd i przekazać jego obsługę NGinn (ale wtedy proces rzeczywiscie zostanie zakończony z błędem)
  • ‘oszukać’ NGinn i powiedzieć że to zadanie jednak zakończyło się sukcesem (wtedy musi jednak dostarczyć prawidłowe dane wyjściowe dla tego zadania, tak jakby rzeczywiście zakończyło się ono poprawnie)

Czasami będzie jeszcze musiał poprawić definicję procesu jeśli błąd powstał na skutek nieprawidłowości w definicji. 

Sposób udostępniana w/w operacji dla administratora może być różny. NGinn oferuje API do tego celu a udostępnienie tego w postaci GUI lub command line to zadanie dla aplikacji używającej NGinn.

Acha, oto definicja pierwszego procesu z dzisiejszego artykułu. W sekcji ‘flows’ jest zdefiniowana obsługa błędu dla zadania T1 – chodzi o przejście z taskOutPort=”Error”:


<?xml version="1.0" encoding="utf-8"?>
<process version="1" name="Failure" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <dataTypes>
    </dataTypes>
    <variables>
		<variable name="v1" type="int" dir="In" required="true" />
    </variables>
    <body>
        <places>
            <place id="start" type="Start" />
            <place id="end" type="End" />
        </places>
        <tasks>

			<task id="T1" type="ReceiveMessage">
            </task>

			<task id="T2" type="Timer" splitType="AND">
            </task>

            <task id="T3_F" label="T1 error" type="Empty" splitType="AND">
			</task>

        </tasks>
        <flows>
            <flow from="start" to="T1" />
            <flow from="T1" to="T2" />
			<flow from="T1" taskOutPort="Error" to="T3_F" />
			<flow from="T3_F" to="end" />
			<flow from="T2" to="end" />
        </flows>
    </body>
</process>

Definicja procesu (4) – anulowanie zadań

Dzisiejszy artykuł będzie o sposobach anulowania zadań w procesie – to znaczy o tym, jak spowodować aby jakieś zadanie które jest w trakcie realizacji zostało anulowane po wystąpieniu określonego zdarzenia. Może nie jest to oczywiste na pierwszy rzut oka, ale taka funkcjonalność jest bardzo często potrzebna. Jednym z takich przypadków jest decyzja post factum (deferred choice), omówiona w tym artykule. Deferred choice jest jednak dość szczególnym przypadkiem stosowanym w określonej sytuacji, a w NGinn mamy do dyspozycji trochę więcej konstrukcji z anulowaniem.

Jako przykład na dziś weźmy sobie taki scenariusz ‘biznesowy’:

Do firmy wpływają reklamacje od klientów. Każda reklamacja powinna zostać obsłużona w ciągu 30 dni. Reklamacja jest wciągana do systemu ‘Trouble Ticketing’ i w tym systemie przypisywana do odpowiedniej jednostki organizacyjnej. Chcemy aby system automatycznie przypominał tym ludziom że zbliża się termin na rozpatrzenie reklamacji – na przykład poprzez wysłanie wiadomości email na 10 dni przed terminem rozpatrzenia, potem na 3 dni przed terminem no i  w momencie upływu terminu rozpatrzenia – wtedy z kopią do szefa działu obsługi reklamacji. Kiedy reklamacja zostanie rozpatrzona (niezależnie od tego z jakim wynikiem), system powinien zaprzestać wysyłania e-maili z eskalacjami.

Jest to prosty przykład i można go zaimplementować na wiele różnych sposobów – na przykład tak:

 

Proces reklamacji

Proces reklamacji

W momencie startu tego procesu wykonywane jest zadanie ‘fork’ – nie robi ono nic oprócz uruchomienia czterech równoległych ścieżek w procesie. Pierwsza ścieżka kończy się zadaniem ‘Obsłuż reklamację’ – jest to zlecenie obsługi reklamacji przekazywane do Działu Obsługi Reklamacji. Pozostałe trzy ścieżki odpowiadają za eskalacje. Na każdej z nich jest zadanie ‘Timeout’ odliczające odpowiedni okres czasu (odpowiednio 20, 27 i 30 dni od daty startu), po upływie tego czasu uruchamiane są zadania eskalacyjne wysyłające odpowiedniej treści email.

Bez użycia anulowania ścieżki te wykonują się niezależnie od siebie, czyli eskalacje będą wysyłane nawet gdy zadanie ‘Obsłuż reklamację’ się zakończy. Aby przerwać wysyłanie eskalacji po zakończeniu obsługi reklamacji wprowadzamy przejścia anulujące, oznaczone na rysunku czerwoną przerywaną linią. Każde takie przejście po zakończeniu zadania ‘Obsłuż reklamację’ zabiera wszystkie tokeny z miejsca do którego prowadzi (o ile te tokeny tam są) – w tym przypadku chodzi o miejsca e1, e2 i e3. Zabranie tokenów z miejsc e1, e2 i e3 oznacza że jeśli któreś z zadań ‘Timeout’ jest aktywne to straci ono wymagany token na wejściu i zostanie anulowane. Oczywiście jeśli któryś z Timeoutów już się wykonał to zjadł token ze swojego wejścia, wtedy przejście anulujące nie będzie miało żadnego efektu.  Osiągamy w ten sposób swój cel – po zakończeniu zadania ‘Obsłuż reklamację’, jeśli były jakieś oczekujące eskalacje to zostaną anulowane a proces będzie się mógł zakończyć bo już nie będzie tokenów poza miejscem końcowym.

Definicja naszego procesu wygląda tak:

<?xml version="1.0" encoding="windows-1250"?>
<process version="1" name="Reklamacje" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    <dataTypes>
    </dataTypes>
    <variables>
		<variable name="v1" type="int" dir="In" required="true" />
    </variables>
    <body>
        <places>
            <place id="start" type="Start" />
            <place id="end" type="End" />
			<place id="e1" />
			<place id="e2" />
			<place id="e3" />
        </places>
        <tasks>
            <task id="T0" type="Empty" splitType="AND" label="fork">
            </task>

			<task id="T1" type="Manual" label="Obsluz reklamacje">
				<parameters>
				</parameters>
            </task>

			<task id="TE1" type="Timer" label="Timeout - 10 dni">
				<parameters>
					<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(20)</expr></param>
				</parameters>
            </task>

			<task id="TE2" type="Timer" label="Timeout - 3 dni">
				<parameters>
					<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(27)</expr></param>
				</parameters>
            </task>

			<task id="TE3" type="Timer" label="Timeout">
				<parameters>
					<param variable="ExpirationDate"><expr>DateTime.Now.AddDays(30)</expr></param>
				</parameters>
            </task>

            <task id="ESK1" label="Eskalacja 1" type="SendNotification" splitType="AND">
			</task>

			<task id="ESK2" label="Eskalacja 2" type="SendNotification" splitType="AND">
			</task>

			<task id="ESK3" label="Eskalacja 3" type="SendNotification" splitType="AND">
			</task>

        </tasks>
        <flows>
            <flow from="start" to="T0" />
            <flow from="T0" to="T1" />
			<flow from="T0" to="e1" />
			<flow from="T0" to="e2" />
			<flow from="T0" to="e3" />
			<flow from="T1" to="e1" cancelling="true" />
			<flow from="T1" to="e2" cancelling="true" />
			<flow from="T1" to="e3" cancelling="true" />
			<flow from="e1" to="TE1" />
			<flow from="e2" to="TE2" />
			<flow from="e3" to="TE3" />

			<flow from="TE1" to="ESK1" />
			<flow from="TE2" to="ESK2" />
			<flow from="TE3" to="ESK3" />
			<flow from="ESK1" to="end" />
			<flow from="ESK2" to="end" />
			<flow from="ESK3" to="end" />
			<flow from="T1" to="end" />
        </flows>
    </body>
</process>

Przejścia anulujące są zapisane w sekcji ‘flows’, to te z atrybutem cancelling=”true”. Mam nadzieję że ten przykład odpowiednio ilustruje przeznaczenie przejść anulujących.

Dzisiejszy przykład pokazuje jeszcze jedną fajną rzecz. Wyobraźmy sobie że zamiast zadania ‘Obsłuż reklamację’ mamy tam cały podproces obsługi reklamacji – z etapami, przechodzeniem przez różne działy, wysyłaniem pism itp. Widać wtedy, że gdy chcemy do takiego procesu dodać eskalacje możemy to zrobić w ogóle w niego nie ingerując – procedura eskalacyjna jest zupełnie zewnętrzna w stosunku do procesu nią objętego.

Zadania atomowe i blokowe. Składnia definicji procesu (3)

simple

Jakiś czas temu próbowałem krótko wyjaśnić z czego składa się proces w NGinn. Dziś kolejne przybliżenie tego tematu – zajmiemy się konstruowaniem prostego procesu. Najprostszy proces w NGinn to taki który nic nie robi. Ale nawet żeby nic nie robić to trzeba jednak trochę się napracować.

Zaczniemy od tego że każdy proces musi mieć miejsce startowe oraz miejsce końcowe (lub kilka miejsc końcowych). A ponieważ nie można łączyć miejsca  z miejscem, to pomiędzy nimi musi znaleźć się jakieś zadanie. Nasz najprostszy proces można podziwiać obok.

Definicja tego procesu to następujący XML:


<?xml version="1.0" encoding="utf-8"?>
<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
    <body>
        <places>
            <place id="start" type="Start" />
            <place id="end" type="End" />
        </places>
        <tasks>
            <task id="T1" type="Empty">
            </task>
        </tasks>
        <flows>
            <flow from="start" to="T1" />
            <flow from="T1" to="end" />
        </flows>
    </body>
</process>

Mamy tu sekcję ‘body’ zawierającą kolejno:

  • miejsca (sekcja ‘places’)
  • zadania (sekcja ‘tasks’)
  • połączenia (sekcja ‘flows’)

Ważne jest to, że zarówno miejsca jak i zadania mają atrybut ‘id’ unikalnie identyfikujący miejsce/zadanie w procesie. Teraz rozbudujmy ten przykład:

 

Przykład 2

Przykład 2

Jest to proces zawierający trzy zadania, z czego jedno jest tzw zadaniem blokowym (composite), czyli takim które zawiera wewnątrz podproces. Definicja tego procesu wygląda tak:


<?xml version="1.0" encoding="utf-8"?>
<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
    <body>
        <places>
            <place id="start" type="Start" />
            <place id="end" type="End" />
        </places>
        <tasks>
            <task id="T1" label="30 min" type="Timer">
				<parameters>
					<param variable="DelayAmount"><expr>TimeSpan.FromSeconds(30)</expr></param>
				</parameters>
			</task>

			<task id="T2" type="Empty">
            </task>

			<composite id="T3" label="">
				<body>
					<places>
                        <place id="T3_start" type="Start" />
                        <place id="T3_end" type="End" />
                    </places>
                    <tasks>
                        <task id="T31" type="Timer">
                        	<parameters>
								<param variable="DelayAmount"><expr>TimeSpan.FromMinutes(1)</expr></param>
							</parameters>
						</task>
                    </tasks>
                    <flows>
                        <flow from="T3_start" to="T31" />
                        <flow from="T31" to="T3_end" />
                    </flows>
				</body>
			</composite>

        </tasks>
        <flows>
            <flow from="start" to="T1" />
			<flow from="T1" to="T2" />
			<flow from="T1" to="T3" />
			<flow from="T2" to="end" />
			<flow from="T3" to="end" />
        </flows>
    </body>
</process>

W tym procesie występuje kilka istotnych szczegółów. Przede wszystkim, w sekcji ‘tasks’ pojawiło się zadanie ‘composite’ – to jest właśnie zadanie blokowe. Wewnętrzna struktura tego zadania jest taka sama jak procesu, czyli zawiera sekcję ‘body’ a w niej miejsca, zadania i połączenia. Oczywiście dalsze zagnieżdżanie jest możliwe – nie ma ograniczenia na głębokość zagnieżdżenia zadań blokowych. W odróżnieniu od blokowych, zadania nie zawierające innych zadań określamy mianem atomowych (tj niepodzielnych) – są to te definiowane poprzez ‘task’.

No właśnie, druga sprawa to task typu ‘Timer’. Jest to zadanie odliczające podany okres czasu. Dla tego zadania podane jest to w parametrze o nazwie ‘DelayAmount’ a wartość parametru jest w formie wyrażenia wyliczanego przed uruchomieniem zadania. Każdy typ zadania w NGinn posiada określony zestaw parametrów – wszystkie są ustawiane w ten sam sposób.

Teraz pora dołożyć do naszego procesu jakieś dane. Robi się to za pomocą sekcji ‘variables’ – omawianej już wcześniej w tym wpisie. Zatem dołóżmy parę zmiennych do naszego procesu oraz zadań, a potem zdefiniujmy mapowania między zmiennymi procesu a zmiennymi zadań.


<process version="1" name="Proc" xmlns="http://www.nginn.org/WorkflowDefinition.1_0.xsd">
	<variables>
		<variable name="userName" type="string" dir="In" required="true" />
		<variable name="accountId" type="string" dir="Out" required="true" />
    </variables>
    <body>
        <places>
            <place id="start" type="Start" />
            <place id="end" type="End" />
        </places>
        <tasks>
            <task id="T1" label="30 min" type="Timer">
				<parameters>
					<param variable="DelayAmount"><expr>TimeSpan.FromSeconds(30)</expr></param>
				</parameters>
			</task>

			<task id="T2" type="Empty">
				<variables>
					<variable name="v1" type="string" dir="In" required="true" />
					<variable name="v2" type="string" dir="Out" required="true" />
				</variables>
				<inputBindings>
					<bind variable="v1"><sourceVar>userName</sourceVar></bind>
				</inputBindings>
				<outputBindings>
					<bind variable="accountId"><expr>data.v2 + '_XYZ'</expr></bind>
				</outputBindings>
            </task>

Dalszą część definicji obciąłem – jest taka sama jak wyżej. Mamy tu zdefiniowane dwie zmienne procesu – zmienną wejściową userName typu string, oraz zmienną wyjściową accountId też typu string. Kolejny ważny szczegół to zmienne zadania ‘T1′ – ma ono dwie zmienne, wejściową v1 oraz wyjściową v2. Dodatkowo, dla tego zadania są zdefiniowane mapowania pomiędzy jego zmiennymi a zmiennymi procesu. Mapowanie wejściowe (inputBinding) mówi że zmienna v1 otrzyma kopię zmiennej userName z procesu – odbędzie się to przy starcie zadania T1. Po zakończeniu zadania T1 natomiast zmienna v2 (wyjściowa) zostanie przedłużona o napis ‘_XYZ’ i przypisana do zmiennej accountId w procesie – mówi o tym mapowanie danych wyjściowych (outputBinding). 

Widzimy zatem jak działa wymiana danych – używamy mapowania danych wejściowych aby ustalić wartości zmiennych wejściowych zadania przy jego starcie, a po zakończeniu zadania wykorzystujemy mapowanie zmiennych wyjściowych na zmienne procesu lub zadania nadrzędnego. To działa rekurencyjnie – jeśli mamy zadanie blokowe to pod-zadania w nim zawarte muszą mapować swoje zmienne na zmienne zadania blokowego, a nie na zmienne procesu – po prostu występuje pełna izolacja danych w zadaniach i nie można sięgać do zmiennych innych zadań. Jedyna dozwolona wymiana danych odbywa się poprzez mapowania i dotyczy wyłącznie zmiennych zadania nadrzędnego lub procesu jeśli działamy bezpośrednio na poziomie procesu.

Kolejnym krokiem powinno być zbadanie jakie rodzaje zadań mamy do dyspozycji w NGinn. Będzie to opisane w kolejnych artykułach, gdyż w tej chwili zestaw zadań w NGinn nie jest jeszcze kompletny. Postaram się też wymyślić jakiś bliższy rzeczywistości przykład procesu do którego stworzenia wykorzystamy nowo poznane zadania.

Definiowanie procesu (2) – rozwidlenia i złączenia

W dzisiejszym odcinku kilka kolejnych elementów języka opisu procesów NGinn – a mianowicie rozwidlenia i złączenia ścieżek w procesie, czyli narzędzia do wprowadzania zawiłości tam gdzie mogło być prosto. Jako źródło przykładów wykorzystamy proces przedstawiony na poniższym schemacie:

 

 

Schemat procesu

Schemat procesu

 

Od razu zaznaczam że proces tu przedstawiony nie ma żadnego głębszego sensu i nie jest oparty na żadnym wziętym z życia przykładzie (dlatego zadania mają klarowne nic nie mówiące nazwy). Łatwo się zorientować gdzie proces ma początek a gdzie koniec oraz że nie ma żadnych zadań blokowych. Miał być prosty i jest prosty.

Rozwidlenia i złączenia

Rozwidlenie (dalej nazywane “split” na cześć miasta w Chorwacji) to miejsce w którym pojedyncza ścieżka w procesie dzieli się na dwie lub więcej ścieżek. Podział taki następuje na wyjściu z zadania, tzn miejscem rozwidlenia jest zadanie. Mamy 3 podstawowe rodzaje rozwidleń:

 

  • AND
  • XOR
  • OR

 

W definicji procesu każde zadanie ma atrybut ’splitType’ mogący przyjąć jedną z powyższych wartości. Domyślnie (gdy go nie ma) przyjmowany jest AND. 

Złączenie (dalej nazywane join) to miejsce spotkania się kilku ścieżek w procesie, przy czym chodzi o spotkanie się na wejściu tego samego zadania. I znowu, mamy 3 rodzaje złączeń, dokładnie takie same jak wymienione wyżej rodzaje rozwidleń, czyli AND, XOR i OR. Nie jest to przypadek, gdyż jeśli zaczniemy konstruować proces okaże się że najczęściej do rozwidlenia typu AND pasuje złączenie typu AND, to samo dla typów XOR i OR.

Uwaga: mamy jeszcze jeden typ rozwidlenia – specjalny przypadek nazywany ‘deferred choice‘, który zasłużył sobie na osobny paragraf niżej.

XOR – czyli punkt decyzyjny

Rozwidlenie typu ‘XOR’ oznacza wybór dokładnie jednego z kilku wariantów. Na rysunku oznaczane jest pustym w środku rombem. 

 

XOR-Split

XOR-Split

Mamy tutaj rozwidlenie na zadaniu T1 rozdzielające nam ścieżkę na 3 warianty prowadzące do zadań T1_1, T1_2 i T1_4. Podczas wykonania procesu, w zależności od spełnienia określonych warunków, zostanie wybrana dokładnie jedna ze ścieżek i proces będzie kontynuowany w zadaniu T1_1 albo T1_2, albo T1_4. Dla programistów: jest to taka konstrukcja ’switch’  albo seria if – else if – end.

Warunki do spełnienia (nazywane warunkami wejściowymi – Input Condition) są przypisywane do każdej ze ścieżek, z wyjątkiem ostatniej – ona nie musi mieć warunku gdyż jest ścieżką domyślną, wybieraną wtedy gdy żadna inna nie zostanie wybrana. A jak stwierdzić która ścieżka jest pierwsza a która ostatnia? Widać to dopiero w definicji procesu – zgodnie z porządkiem w którym są umieszczone przejścia w definicji (można też jawnie podać porządek) – za chwilę to sobie zobaczymy.

Należy pamiętać o jednej rzeczy – w sieciach Petriego nie można łączyć zadania z zadaniem bezpośrednio. Po drodze musi być miejsce (place). Tutaj te miejsca też są, tylko że niewidoczne. Można przyjąć że w połowie każdej z trzech widocznych na rysunku strzałek jest place, czyli tak naprawdę pojedyncze przejście ma dwa etapy. Ma to znaczenie gdy obserwuje się XOR-split w działaniu. Otóż zadziałanie jednego z przejść XOR-splitu powoduje umieszczenie tokenu w miejscu do którego to przejście prowadzi. Dla pozostałych wariantów token nie będzie wyprodukowany. A oto jak wygląda definicja naszego XOR-splita:


<flow from="T1" to="T1_1" label="Yes">
	<inputCondition>data.answer == 'Yes'</inputCondition>
</flow>
<flow from="T1" to="T1_2" label="No">
	<inputCondition>data.answer == 'Yes'</inputCondition>
</flow>
<flow from="T1" to="T1_4" label="Not sure"/>

Widzimy trzy strzałki (flow), dwie z nich zawierają inputCondition – czyli wyrażenie które będzie sprawdzane w momencie napotkania XOR-split. Sprawdzane będą w kolejności występowania i pierwszy spełniony warunek wygeneruje token na wyjściu. Gdy żaden nie będzie spełniony token pojawi się w ostatnim przejściu, czyli tym oznaczonym ‘Not sure’.

A teraz zbadajmy złączenie typu XOR, czyli XOR-join

 

XOR-join

XOR-join

Mamy tutaj złączenie trzech ścieżek w jedną, prowadzącą do zadania T1_E. Złączenie typu XOR oznaczane jest pustym w środku rombem z wchodzącą strzałką. Ta konstrukcja oznacza, że w momencie pojawienia się tokenu na którejkolwiek ze ścieżek wchodzących zadanie T1_E zostanie uruchomione. 

No dobra, można zapytać, a co jeśli token pojawi się na dwóch lub wszystkich trzech ścieżkach jednocześnie?

Po pierwsze, jeśli podziału dokonaliśmy za pomocą XOR-split, to token pojawi się na dokładnie jednej ścieżce i problemu nie ma. No ale jeśli podział był robiony np AND-splitem, to mamy tokeny na wszystkich ścieżkach. W takiej sytuacji XOR-join spowoduje uruchomienie zadania T1_E trzykrotnie, za każdym razem wykonanie tego zadania będzie zjadać jeden token z wejścia aż wyczerpią się wszystkie tokeny. Zwykle takie zachowanie nie jest prawidłowe dlatego należy zwracać uwagę aby XOR-join miał zawsze dokładnie jeden token na wejściu.

 

AND – ścieżki równoległe

Rozwidlenie typu ‘AND’ powoduje wykonanie wszystkich ścieżek które się za nim znajdują, przy czym są one wykonywane równolegle, niezależnie od siebie.

AND-split

AND-split

Na rysunku z prawej jest narysowany AND-split prowadzący z zadania T0 do zadań T1 i T2. Nie ma on żadnego dodatkowego symbolu na strzałce i jest to domyślny rodzaj rozgałęzienia w NGinn (czyli jeśli nie podamy w definicji procesu rodzaju rozgałęzienia to zostanie przyjęty AND).

W tym przypadku zakończenie realizacji zadania T0 powoduje wygenerowanie tokenów na wszystkich wychodzących z niego ścieżkach, co oznacza że zadania T1 i T2 będą mogły zostać uruchomione. Od tej pory przebieg procesu ma dwie niezależne odnogi. Odnogi te pozostaną niezależne do momentu ich złączenia za pomocą AND-join, widocznego na rysunku:

 

AND-join

AND-join

Tutaj zadania T1_E i T2_E należące do równoległych ścieżek łączą się na wejściu do zadania T0_E. Taki zapis oznacza że zadanie T0_E nie może się rozpocząć do momentu zakończenia zarówno zadania T1_E jak i T2_E. Jeśli zakończy się tylko jedno z tych zadań, system zaczeka na zakończenie drugiego i dopiero wtedy uruchomi zadanie T0_E. Zasada działania AND-join jest taka jak w sieci Petriego – zadanie może zostać uruchomione dopiero wtedy, gdy we wszystkich jego miejscach wejściowych znajduje się jakiś token. 

Po zakończeniu zadania T0_E z każdego z jego miejsc wejściowych zostanie zjedzony jeden token, w tym przypadku będą to dwa tokeny. Jeśli teraz na wyjściu z T0_E pojawi się jeden token, to dokonaliśmy scalenia dwóch ścieżek z powrotem w jedną.

 

OR – wybór wielokrotny

Rozgałęzienie typu ‘OR’ jest wersją pośrednią między AND i XOR. Oznacza ono że może zostać wybrane kilka wariantów spośród wszystkich dostępnych. 

 

OR-split

OR-split

Mamy z prawej OR-split na zadaniu T2 i trzy ścieżki wyjściowe oznaczone ‘Pretty’, ‘Loud’ i ‘Tall’. OR-split jest oznaczany rombem z przekątnymi.  Każda ze ścieżek w OR-split ma zdefiniowany warunek (input condition), przy czym, w odróżnieniu od XOR, ostatnia ścieżka również zawiera warunek.

Efekt wykonania OR-split jest taki, ze token pojawia się na każdej ze ścieżek wyjściowych dla której spełniony jest warunek. System wymaga aby zawsze przynajmniej dla jednej ścieżki warunek był spełniony, jeśli żaden nie zostanie spełniony w procesie zostanie zgłoszony błąd. Jeśli wybrane zostanie kilka ścieżek będą one wykonywane równolegle.

Choć OR-split wydaje się bardzo fajnym, uniwersalnym narzędziem łączącym to co najlepsze w AND i XOR to  powinien być stosowany rozważnie, tylko tam gdzie to niezbędne. Jest tak dlatego że komplikuje on logikę procesu i utrudnia wykrycie ewentualnych błędów. Ponieważ nie wiadomo ile ścieżek wyjściowych się uaktywni po OR-join, nie można tych ścieżek łączyć za pomocą AND-join. XOR – join też jest niewskazany po OR-split bo jego działanie jest nieoczekiwane dla kilku tokenów na wejściu. Czyli zostaje nam OR-join.

 

 

OR-join

OR-join

OR-join jest oznaczany na schemacie rombem z przekątnymi umieszczonym na końcu strzałki. Jest to bardzo ciekawy przypadek złączenia, mówi on mniej-więcej: Poczekaj na wszystkie tokeny które mogą się pojawić na wejściu do zadania T2_E i dopiero wtedy uruchom zadanie T2_E. Czyli NGinn musi się jakoś domyślić ile tokenów może się pojawić na wejściu T2_E i czy są to już wszystkie tokeny które mogły tam dotrzeć, czy też należy czekać na nadejście kolejnych. Ponieważ tokeny mogą przychodzić na wejście OR-join z dowolnych miejsc w procesie (tzn z takich miejsc z których istnieje droga) to NGinn realizując OR-join musi brać pod uwagę nie tylko tokeny na wejściu do zadania T2_E, ale również stan pozostałych tokenów w sieci i jeśli stwierdzi że albo w sieci nie ma już więcej tokenów, albo te co są nie mają szans dotrzeć na wejście T2_E, dopiero wtedy uruchomi T2_E.

Uwaga: w obecnej wersji systemu użycie OR-join wymaga również podania listy miejsc z których tokeny mogą dotrzeć do OR-join. W przyszłości system automatycznie wyznaczy sobie taką listę.

 

Deferred choice – metoda faktów dokonanych

Czasami nie jesteśmy w stanie na etapie projektowania procesu powiedzieć która ścieżka powinna zostać wybrana. Może być tak wtedy, gdy nie znamy kryteriów według których określona ścieżka powinna zostać wybrana, lub gdy wybór jest dyktowany czynnikiem zewnętrznym w stosunku do naszego procesu. Mam nadzieję że zaraz to wyjaśnię.

 

Deferred choice

Deferred choice

Mamy tu miejsce p0, z którego wychodzą dwa zadania – T_V1 i T_V2. Każde z tych zadań do uruchomienia wymaga jednego tokenu – tego który znajduje się w p0. Czyli oba zadania mogą być uruchomione jeśli tylko token tam się pojawi. Natomiast tylko jedno z zadań może się zakończyć – zakończenie tego zadania oznacza skonsumowanie tokenu z p0, a wtedy drugie zadanie straci możliwość wykonania. NGinn w takim przypadku anuluje to zadanie.

Jakie jest praktyczne zastosowanie deferred choice? Po pierwsze możemy zaoferować kilka ścieżek do wyboru – wtedy prezentowane są wszystkie możliwości, a wybór jednej z nich powoduje anulowanie pozostałych. Nie wnikamy w to jakie są kryteria wyboru określonej ścieżki.

Drugim typowym przypadkiem jest implementacja ograniczenia czasowego (timeout) na wykonanie określonego zadania. Weźmy przypadek pisma do urzędu. Urzędnik ma powiedzmy 30 dni na odpowiedź na to pismo. Po upływie 30 dni chcemy uruchomić jakąś procedurę eskalacyjną. W naszym przykładzie niech T_V1 będzie zadaniem ‘Odpowiedz na pismo obywatela X’, zaś T_V2 – zegarem (zadanie typu ‘timer’) odliczającym 30 dni. W momencie wejścia tokenu do p0 startują oba zadania: urzędnik dostaje zlecenie obsłużenia pisma, zaś timer zaczyna odliczanie 30 dni. Jeśli urzędnik zdąży zakończyć zadanie przed upływem 30 dni, w momencie zakończenia zadania timer zostaje anulowany. Jeśli nie zdąży to zakończy się zadanie z zegarem i zostanie anulowane jego zlecenie. Inaczej niż na tym rysunku, za zadaniem T_V2 (timer) możemy umieścić procedurę eskalacji – np wysłanie powiadomienia do kierownika jednostki albo zlecenie wykonania telefonu do klienta z informacją ze jego pismo jest nieważne i musi zostać złożone ponownie ;)

Pętla

 

Pętla - XOR

Pętla - XOR

Oto przykład realizacji pętli za pomocą XOR-split oraz XOR-join. Startujemy w zadaniu T1_2, po jego wykonaniu mamy XOR-split, prowadzący do zadania T1_3 oraz T1_E. Z zadania T1_3 wracamy do zadania T1_2. W ten sposób stworzyliśmy pętlę. Warunek wyjścia z pętli umieszczamy w wyjściu z zadania T2. Zwykle zadanie T2 jest tylko punktem decyzyjnym, zaś właściwe ‘ciało’ pętli to zadanie T1_3. Można oczywiście jeszcze krócej i zwięźlej – wyjście z zadania T1_2 można skierować bezpośrednio do tego samego zadania, wtedy T1_2 będzie ciałem pętli a po jego zakończeniu będzie sprawdzany warunek wyjścia z pętli – czyli znajoma pętla repeat…until.

Uwaga: zastosowanie AND-split w pętli stworzy nam generator tokenów – czasami może być przydatny, ale zwykle to błąd.

Na tym przykładzie zakończymy temat podstawowych struktur sterujących w NGinn. Do omówienia zostały bardziej skomplikowane konstrukcje, czyli obsługa błędów, anulowanie zadań, zadania blokowe i podprocesy. Wszystko to w kolejnych odcinkach.