Azure Durable Functions, deel 7: Human Interaction

Zoals ik aan het einde van de vorige post al schreef, hebben veel workflow engines de mogelijkheid om het systeem te pauzeren tot er een signaal van buiten komt die aangeeft dat we verder kunnen.

Nu is Durable Functions geen echte workflow engine maar we kunnen dit wel hier ook inbouwen. Nu is het op zich niet zo ingewikkeld, maar er zitten wat haken en ogen aan. Immers: op het moment dat je de mens een onderdeel maak van een geautomatiseerd proces moet je rekening houden met het grillige gedrag van mensen. Computers zijn voorspelbaar. Mensen niet.

Het proces is dus vrij recht-toe, recht-aan. Maar we willen graag een robuuste omgeving maken die rekening houdt met alle eventualiteiten.

De proces stap die we hier behandelen is de volgende: stap 3 in het proces, genaamd “Get Approval”

Het idee is dat wij als bestuur van Stichting dotNed toch wel even kijken wie zich allemaal aanmelden zodat we sommige mensen kunnen weigeren (doen we nooit hoor…) Hoe doen we dat? Simpel: op het moment dat iemand zich aanmeldt, krijgen we een mailtje in een centrale mailbox met daarin de tekst:

“Persoon {x} heeft zich aangemeld voor de bijeenkomst. Klik {link A} om deze persoon te accepteren en klik {link B} om deze persoon te weigeren.”

Of woorden van die strekking. Door link A of link B te kiezen kunnen we onze voorkeur aangeven.

Onze worfklow zal dus een mail moeten versturen. Maar: daarna moet de flow gepauzeerd worden tot iemand op een van beide links geklikt heeft. Die link zal dan een trigger moeten hebben die de workflow weer aanzwengelt zodat we verder kunnen gaan met het accepteren of afwijzen.

Nu hebben wij als bestuur ook nog een ‘normale’ baan, we hebben een gezin, we hebben het best druk. Dus we missen nog wel eens een dergelijk mailtje. Wat moeten we dan doen? Het is een beetje flauw om de mensen die zich willen aanmelden heel lang te laten wachten. Dus we hebben afgesproken dat als het bestuur niet binnen 24 uur reageert, het proces er van uitgaat dat we deze persoon wel goed keuren. En dan gaat het proces weer verder.

Dat houdt dus in dat we in de gaten moeten houden wanneer een mail verstuurd wordt en dan moet er een timer gaan lopen. We moeten die 24 uur in de gaten houden.

We hebben nu een paar uitdagingen:

  1. Hoe pauzeren we een flow?
  2. Hoe triggeren we de flow zodat hij weer gaat lopen?
  3. Hoe bouwen we een time out in?
  4. Hoe zorgen we er voor dat het systeem veilig blijft en hack-vrij (we hebben te maken met professionele it’ers die langs willen komen, die vinden niets leuker dan het proces hacken).
  5. Hoe bouwen we dit zonder het overzicht kwijt te raken?

Als je mee bouwt, ga dan even naar de website van SendGrid. SendGrip is een API waarmee je emais kunt versturen via een eenvoudige REST API. Het account is gratis maar dan heb je een beperking van maximaal 100 berichten per dag. Voor ons is dat meer dan genoeg natuurlijk. Als je je aanmeldt, kun je een API key aanmaken. Ga daarvoor op de SendGrid site naar je Dashboard. In het menu links onderin staat “Settings”. Kies daar voor API Keys:

SendGrid Key management scherm

Klik dan op Create API Key. Hou de key geheim! Deel die niet! Je kunt ze altijd weer revoken maar met deze key kan iedereen op jouw account mails versturen. Er zijn heel veel spammers die dat best fijn zouden vinden.

Aanmaken nieuwe key voor SendGrid

De key moet een naam hebben, en je kunt bepalen wat je er mee kunt. In dit geval laat ik hem op Full Access staan.

De SendGrid key

Je krijgt nu een nieuwe key, Dit is de enige keer dat je hem te zien krijgt op de website van SendGrid dus maak er een kopie van (in jouw geval zal er geen zwarte balk over heen staan, maar ik wil mijn key toch geheim houden.) Ik sla de key op in mijn local.settings.json file zodat ik hem later kan uitlezen:

De SendGrid key in de settings file

Goed. Dat hebben we gedaan.

Wachten op events: twee stappen

Het proces is op te knippen in twee losse stappen:

  1. Het versturen van de email, waarna het proces wacht
  2. Het verder gaan van het proces nadat een link is geklikt of de time-out verstreken is.

Laten we eens beginnen met stap 1. Om precies te zijn: we gaan eerst de activity maken.

Aangezien we SendGrid gebruiken voor het versturen van de mail, moeten we de SendGrid Nuget package gebruiken. Laten we die eerst toevoegen. Er zijn er een aantal verschillende in de Nuget gallery, dus pak wel de goede. Namelijk: Microsoft.Azure.Webjobs.Extensions.SendGrid.

SendGrid Nuget package

Veilig omgaan met workflow instances

Het is tijd voor de activity. Zoals we al gezien hebben, krijgt een activity niet meer informatie dan dat hij nodig heeft om zijn werk te doen. Maar wat hebben we nu nodig in onze email-verzend activity? In ieder geval data over de bezoeker. Ik hou het minimaal en geef alleen het email adres van de bezoeker mee.

Maar: als het proces gepauzeerd wordt en daarna weer verder moet gaan, moet de workflow wel weten welke instance we verder willen laten gaan. Dat moet op de een of andere manier in de link gezet worden die we naar het bestuur versturen. Dus we moeten de instanceId van de workflow ook meegeven. We maken daar dus weer een DTO van:

namespace MeetingRegistration
{
    public class BoardEmailData
    {
        public string InstanceId { get; set; }
        public string Email { get; set; }
    }
}

Er is wel een puntje met betrekking tot de InstanceId. Dat is eigenlijk gevoelige informatie die je niet wilt delen met de buitenwereld. Ok, om iets zinnigs te doen met de workflow (weet je nog die statusQueryGetUri die we gebruiken?) moet je wel een secret key hebben maar als je die hebt EN je hebt de instance Id dan heb je ineens de mogelijkheid om het proces van buiten af te beinvloeden. Dus hoe minder info we delen met de buitenwereld hoe beter. Daarnaast: de InstanceId is een implementatie nummer; die hoor je sowieso niet te delen. We moeten dus wat anders verzinnen.

De oplossing is als volgt:

  1. We maken een storage ergens en daarin slaan we de InstanceId op.
  2. We gebruiken als key voor die entry in te storage een random, uniek nummer (een GUID zou mooi zijn).
  3. Als iemand de link klikt, dan geven we in die link de key mee (die GUID dus). De link verwijst naar een nieuwe Azure Function (niet durable, gewoon een GET based, HTTP Triggered Function zoals onze starter function).
  4. Als parameter hebben we in die function nu de GUID. Daarmee halen we de InstanceId van de workflow op
  5. We zeggen tegen de Durable Functions Runtime dat de workflow met dit unieke ID verder mag gaan, en we geven de approved of denied status mee.

Klinkt ingewikkeld? Valt heel erg mee!

We gaan met TableStorage werken, dus we moeten ook daarvoor de Nuget packages installeren: we gebruiken Microsoft.Azure..Webjobs.Extensions.Storage.

Storage Nuget extensions

Goed. Tijd voor wat code. Ik geef de lege activity alvast, dan doen we de implementatie later als ik uitgelegd heb hoe het een en ander werkt.

We hebben een class nodig die de data voorstelt die we gaan opslaan in de TableStorage.

using System;
using System.Collections.Generic;
using System.Text;

namespace MeetingRegistration
{
    public class StoredOrchestration
    {
        public string PartitionKey { get; set; }
        public string RowKey { get; set; }
        public string OrchestrationId { get; set; }
    }
}

Deze class bevat de data die we op gaan slaan in de database. Als je nog niet eerder met TableStorage gewerkt heb: het is redelijk simpel.

De TableStorage heeft een Table (duh) en die tabel bevat records. Ieder record of entry heeft een composite key: een key die bestaat uit twee delen. Als eerste is er de Partitionkey en als tweede de Rowkey. We gebruiken een vaste PartitionKey om aan te geven dat deze entry gaat over een goed te keuren attendee. De Rowkey wordt die random GUID die we gebruiken. Als laatste heb je de mogelijkheid om zelf kolommen toe te voegen. Wij gebruiken er maar 1: de OrchestrationId die aangeeft welke instance van de workflow we gebruiken.

Waarom slaan we de rest van de data niet op, kun je je afvragen? Het ligt voor de hand om ook het email adres en/of de naam van de bezoeker op te slaan. Het antwoord is natuurlijk: we hebben het niet nodig. Als we de orchestrationId hebben, kan de orchestrator daarmee alle data ophalen die nodig is. Weet je nog? Als de activity klaar is, gaat de orchestrator opnieuw beginnen en haalt alle data op uit zijn eigen TableStorage. Daarin hebben we de naam en het email adres.

Laten we een begin maken met de activity zelf:

[FunctionName("A_SendBoardEmail")]
public static Task SendBoardEmail(
    [ActivityTrigger] BoardEmailData boardEmailData,
    [Table("Attendees", Connection = "AzureWebJobsStorage")] 
    out StoredOrchestration storedOrchestration,
    ILogger log)
{
    var uniqueKey = Guid.NewGuid().ToString("N");

    storedOrchestration = new StoredOrchestration()
    {
        PartitionKey = "AttendeeApprovals",
        RowKey = uniqueKey,
        OrchestrationId = boardEmailData.InstanceId
    };

    return Task.CompletedTask;
}

Er gebeurt nogal wat hier. We gaan hem even uit elkaar trekken.

Als eerste de FunctionName. Die kennen we nu wel. De activity zelf is een public static async method die een Task teruggeeft. De eerste parameter is de BoardEmailData die we net gemaakt hebben en die markeren we als ActivityTrigger. Tot zover geen rocket science. Dan de tweede parameter op regel 4 en 5: we maken gebruik van TableStorage. Hoe dit werkt is als volgt:

Door het gebruik van het attribuut [Table] geven we aan dat de data in storedOrchestration in de database moet komen of daar van uit moet komen. We geven de tabel naam mee (“Attendees”) en we geven de Connection mee. Dit is de naam van de setting in je local.settings.json file en bij ons staat dat op UseDevelopmentStorage=true. Dat is de default waarde, in productie verander je dat naar het echte storage account die je wilt gaan gebruiken.

Doordat we de StoredOrchestration als ‘out’ parameter aangeven, zal de runtime een entry in de tabel plaatsen als je een nieuwe instance van die class aanmaakt en de functie verlaten wordt. We hoeven dus niets te doen met connections, insert-statements, database code en de rest. De code die je nu ziet is genoeg om een entry in de TableStorage te plaatsen.

We maken een unieke sleutel aan. Dit is dus iets wat wel in de activity mag maar niet in een orchestration functie. We zitten in de activity dus dat werkt.

We maken een instance van de StoredOrchestration aan. De PartitionKey zet ik op AttendeeApprovals en de Rowkey op de nieuwe key die we net gemaakt hebben. We geven ook de orchestrationId mee die we mee gaan krijgen vanuit de orchestration.

Laten we dat gelijk maar even regelen: in de hoofd orchestration, na het aanroepen van de sub orchestration, plaatsen we de volgende regels:

if(!ctx.IsReplaying)
    log.LogWarning("Sending email to the dotNed board members.");

await ctx.CallActivityAsync("A_SendBoardEmail",
    new BoardEmailData {Email = attendee.Email, InstanceId = ctx.InstanceId});

Niets nieuws hier. We loggen (eenmalig!) en we roepen de activity aan met de data die we nodig hebben.

We moeten nu alleen de email nog versturen. Dat doen we in de activity en dat gaan we nu doen.

Om dat voor elkaar te krijgen moeten we de signature van de method aanpassen. Na de [Table] parameter maken we nu ook een [SendGrid] parameter:

[FunctionName("A_SendBoardEmail")]
public static Task SendBoardEmail(
    [ActivityTrigger] BoardEmailData boardEmailData,
    [Table("Attendees", Connection = "AzureWebJobsStorage")]
    out StoredOrchestration storedOrchestration,
    [SendGrid(ApiKey = "SendGridAPIKey")] 
    out SendGridMessage sendGridMessage,
    ILogger log)
{

We hebben net gezien dat als we een out parameter hebben en die markeren met [Table] dat die dan automatisch een entry aanmaakt in de TableStorage. Voor SendGrid hebben we een zelfde techniek: we definieren een out parameter van het type SendGridMessage (dit is de email) en we markeren deze met [SendGrid]. We geven de API key mee, dat is die in de local.settings.json staat (zie boven in deze post). De runtime zal deze er in zetten.

Dus: als we nu een instance aanmaken van deze SendGridMessage zal SendGrid deze automatisch versturen. Geen gedoe met het aanmaken van REST calls, aanroepen van de API enzovoorts. Komt ie!

[FunctionName("A_SendBoardEmail")]
public static Task SendBoardEmail(
    [ActivityTrigger] BoardEmailData boardEmailData,
    [Table("Attendees", Connection = "AzureWebJobsStorage")]
    out StoredOrchestration storedOrchestration,
    [SendGrid(ApiKey = "SendGridAPIKey")] 
    out SendGridMessage sendGridMessage,
    ILogger log)
{
    var uniqueKey = Guid.NewGuid().ToString("N");

    storedOrchestration = new StoredOrchestration
    {
        PartitionKey = "AttendeeApprovals",
        RowKey = uniqueKey,
        OrchestrationId = boardEmailData.InstanceId
    };

    var myHostName = System.Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME");

    var linkBase = @"http://{0}/api/processApproval/{1}/{2}";
    var approval = string.Format(linkBase, myHostName, uniqueKey, "true");
    var deny = string.Format(linkBase, myHostName, uniqueKey, "false");

    var body =
        "<html>" +
        "<h1>" +
        "Approval needed" +
        "</h1>" +
        "<p>" +
        $"Please approve at<a href= \"{approval}\" > this link</a>." +
        "<br />" +
        $"Or deny at<a href= \"{deny}\" > this link</a>" +
        "<br/>" +
        $"for user with email {boardEmailData.Email}." +
        "</p>" +
        "<p>" +
        "<h3>" +
        "Thanks!" +
        "</h3>" +
        "</p> " +
        "</html> ";

    var from = new EmailAddress("dennis@vroegop.org");
    var to = new EmailAddress("dotneddemo@outlook.com");

    sendGridMessage = new SendGridMessage()
    {
        From = from,
        Subject = "Please approve!",
        HtmlContent = body
    };
    sendGridMessage.AddTo(to);

    return Task.CompletedTask;
}

Ik geef je even de hele activity. Ik maak een paar strings aan voor de links. De host name vraag ik op via System.Environment.GetEnvironmentVariable(“WEBSITE_HOSTNAME”). In ons geval geeft dat de string “localhost:7071” terug. Die plakken we in de base string. Die base string zal er dus als volgt uit zien: http://localhost:7071/api/processApproval/%5BmyGuid%5D/true (of false bij afwijzen. Deze URL gaat verwijzen naar een nieuw te bouwen HTTP triggered Azure Function. Die maken we de volgende post wel.

Daarna maken we de HTML body voor de mail, zetten de adressen goed en maken een instance van de SendGridMessage aan. Dat zorgt ervoor dat de mail verzonden wordt als we de activity verlaten.

Als we dit nu runnen, zal het systeem een entry aanmaken in de tabel Attendees (als die nog niet bestaat, wordt die gelijk maar even aangemaakt) en daarna wordt er een email verstuurd. Ik verstuur hem even vanaf mijn eigen mail adres naar een dummy bestuurs-mailbox, dus pas dat aan.

In de Storage Explorer kun je het resultaat zien:

Storage Explorer met de nieuwe tabel en data

Je ziet: er is een nieuwe tabel aangemaakt en deze heeft nu de info over de orchestrationId die we moeten gaan goed- of afkeuren.

In mijn mailbox zie ik het volgende:

De link doet het niet natuurlijk, maar zou je er op klikken dan zie je in de URL dat de querystring parameter voor de unique key hetzelfde is als de Rowkey in de tabel. Dus in de nog te schrijven Azure Function kunnen we die data uit de table storage halen, de OrchestrationId ophalen en verder gaan.

Wachten…

Prima. Maar we wachten nog steeds niet en dat was het onderwerp van deze post. Hoe doen we dat? We moeten wachten tot iemand op een van die twee links klikt.

We gaan terug naar onze orchestrator. Na de call naar A_SendBoardEmail schrijven we de volgende code:

var boardHasApproved = await ctx.WaitForExternalEvent<bool>(
    "E_BoardHasApproved", 
    TimeSpan.FromSeconds(30), 
    true);

if (!boardHasApproved)
{
    return new RegistrationResult()
    {
        IsSucces = false,
        Reason = "Board did not approve. Sorry!"
    };
}

We roepen in de context de method WaitForExternalEvent<bool>(“E_BoardHasApproved”) aan. Er moet dus een externe event gebeuren willen we verder gaan. Tot die tijd is de orchestration niet meer levend, net als met activities. We geven een time-out mee van 30 seconden. Dat is wat kort om de board te laten reageren op de email, maar ik heb geen zin om voor iedere test 24 uur te moeten wachten. Idealiter sla je dit op in de Config (en lees die uit in een aparte activity!) maar nu doen we het even zo.

Als die timeout plaatsvind voor het event er is geweest, krijgen we een TimeOutException. Die kan je afvangen en melden dat er geen reactie is geweest op tijd. Maar wij hadden in ons geval al afgesproken dat als het bestuur niet binnen 24 uur (of 30 seconden zoals we hier doen) reageert dat we dan die aanvraag goedkeuren. Dus we geven een default return value van True mee aan onze WaitForExternalEvent. Na de timeout krijgen we dus automatisch true terug.

Laten we dit eens gaan testen.

We starten de flow, openen een browser met de juiste URL en laten hem gaan. Zo gauw de status pagina verschijnt, klikken we op de url bij statusQueryGetUri. Ik krijg dan het volgende resultaat.

// 20191104101214
// http://localhost:7071/runtime/webhooks/durabletask/instances/a05de0c734e64db181028163991c21f9?taskHub=DurableFunctionsHub&connection=Storage&code=rCJjUkbfRFrmoxIzf6CU8mxBnxUeOnLY5qr5DJ04C80FG87HgsDm6w==

{
  "name": "O_PerformRegistration",
  "instanceId": "a05de0c734e64db181028163991c21f9",
  "runtimeStatus": "Running",
  "input": {
    "$type": "MeetingRegistration.Attendee, MeetingRegistration",
    "Name": "Dennis",
    "Email": "dennis@vroegop.org",
    "WantsTweet": true
  },
  "customStatus": null,
  "output": null,
  "createdTime": "2019-11-04T09:12:08Z",
  "lastUpdatedTime": "2019-11-04T09:12:12Z"
}

Je ziet dat de status nu running is. En dat blijft hij ook tot we het proces verder laten gaan (nogmaals: next post!) of tot de timeout plaats vindt. Wacht even, doe een paar keer refresh van deze pagina en je ziet hem op een gegeven moment veranderen van Running naar Completed, met de bijbehorende data.

Je ziet hoe eenvoudig het is om te wachten op externe events en er een time out bij te bouwen. Je hoeft daar zelf eigenlijk niets voor te doen.

Onder water gebeurt er wel heel veel: er wordt een nieuw activity aangemaakt met een timer. Die timer gaat af als de tijd verlopen is. Als dat zo is, dan wordt er een message in de queue gezet, die de orchestrator krijgt. Dan wordt de activity die wacht op de message met approval (of denial) gestopt en het resultaat wordt genegeerd. Mocht er nu wel op tijd gereageerd zijn, dan zal de runtime de timer stoppen en disposen.

Dit is een hoop plumbing. Gelukkig hoeven we dat niet zelf te schrijven. Iemand die de code van de orchestrator ziet en een beetje kan programmeren, ziet meteen wat de flow is en hoe dat allemaal samenhangt.

Er is nog een dingetje: deel 2 van dit verhaal. Hoe gaan we nu zorgen dat dat event verstuurd wordt waar de activity zo met smart op zit te wachten?

Dat vind je in het volgende deel!

Gepubliceerd door Dennis Vroegop

Passionate Azure Cloud Solutions Architect. I am a enthusiastic guitar player (though not as good at it as I'd like to be) and in my daytime job I teach software developers to be better at their job. Married to my wonderful wife Diana with whom I try to raise our daughter Emma.

One thought on “Azure Durable Functions, deel 7: Human Interaction

Geef een reactie

Vul je gegevens in of klik op een icoon om in te loggen.

WordPress.com logo

Je reageert onder je WordPress.com account. Log uit /  Bijwerken )

Google photo

Je reageert onder je Google account. Log uit /  Bijwerken )

Twitter-afbeelding

Je reageert onder je Twitter account. Log uit /  Bijwerken )

Facebook foto

Je reageert onder je Facebook account. Log uit /  Bijwerken )

Verbinden met %s

Deze site gebruikt Akismet om spam te bestrijden. Ontdek hoe de data van je reactie verwerkt wordt.

%d bloggers liken dit: