Azure Function Queue Message: Ottimizzare i metodi ricorsivi

Non è la prima volta che mi imbatto nell’utilizzo delle Azure Function in modalità Queue Trigger, ma questa volta l’utilizzo è stato ben diverso dalle precedenti. Prima di addentrarmi nel vivo dell’articolo vorrei riportare il post che ho scritto sul mio profilo linkedin nei giorni scorsi:

Avevo un monolite ricorsivo (non scritto da me) che prendeva quintali di timeout su timeout rendendo difficoltoso l’utilizzo del sistema. L’ho studiato e l’ho riscritto in questi giorni. Il segreto è stato quello di spostare il codice in azure function con Queue Trigger dandogli una sequenza temporale corretta. Da quel momento basta dare il via al tutto con la prima chiamata e poi si vedrà scodare il tutto che è un piacere. Perché scrivere codice complesso e rognoso (facendo per di più attendere l’utente) quando si può spacchettare e delegare al cloud senza interruzioni di servizio?

Ora provo ad entrare nel dettaglio di questa implementazione e di come il codice è stato trasformato rendendo il sistema utilizzabile.

MVC Controller - Codice monolitico

La prima volta che ho utilizzato quel controller senza leggerne il codice non ho avuto problemi essendo in un ambiente di test locale col database praticamente vuoto. Il problema è nato la prima volta che l’ho eseguito su un database produttivo dove la presenza di dati era imponente. Chiamavo il controller ed il tutto si “congelava” senza tornare una risposta. Monitorando lo stato del database lo vedevo abbastanza impiccato e questo rendeva il sistema inutilizzabile. A quel punto ho preso il coraggio in mano ed ho aperto il codice sorgente e giuro mi ha preso male solo leggendo le prime righe di codice

1
2
3
4
5
6

public async Task<ActionResult> DoSomething()
{
	Server.ScriptTimeout = 1200;
 	Session.Timeout = 25;

Con quelle due righe lo sviluppatore padre di quel codice dichiara:

Per come ho fatto il sistema, sono ben cosciente che sarà una cosa lunghissima questa che cerchi di fare. Provo a pararmi il fondo schiena alzando i timeout in questo modo

Ovviamente per come hai fatto (male) il sistema questo non è sufficiente perché i timeout si vincono ugualmente. Se vi state chiedendo il come mai di questi timeout nonostante una dichiarazione così alta la risposta è semplice.

L’istruzione a seguire chiamava un metodo ricorsivo che effettuava operazioni pesanti sul database un numero elevato di volte. (=durante i test siamo arrivati oltre i DUEMILA accessi al metodo)

Azure Function - Queue Message (nuovo codice)

Il nuovo codice ha delegato tutto il computo dei dati in Cloud sfruttando le Azure Function ed i Queue Message. Come? Semplicemente modificando il metodo DoSomething() nel seguente modo

  • Rimozione della dichiarazione di timeout elevato

  • Dichiarazione con Lazy Load di: CloudStorageAccount / CloudQueueClient / CloudQueue / JsonSerializerSettings

  • Rimozione del metodo ricorsivo (si, avete lette bene)

  • Calcolo con una query ben strutturata sfruttando Dapper e non EntityFramework -che ricordo essere il male assoluto a livello di performance- su cui si base il progetto originale

  • Inserimento di un messaggio nella coda appena definita col campo chiave ottenuto dalla query

  • Spostamento del codice originale all’interno di una nuova Azure function che scatta con l’inserimento di un messaggio nella coda.

Ora veniamo ad una domanda spontanea: Perché questo codice dovrebbe andare senza inchiodare il tutto? La risposta è semplice e si trova in CloudQueue.AddMessage: CloudQueue - AddMessage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

//
// Summary:
//     Adds a message to the queue.
//
// Parameters:
//   message:
//     A Microsoft.WindowsAzure.Storage.Queue.CloudQueueMessage object.
//
//   timeToLive:
//     A System.TimeSpan specifying the maximum time to allow the message to be in the
//     queue, or null.
//
//   initialVisibilityDelay:
//     A System.TimeSpan specifying the interval of time from now during which the message
//     will be invisible. If null then the message will be visible immediately.
//
//   options:
//     A Microsoft.WindowsAzure.Storage.Queue.QueueRequestOptions object that specifies
//     additional options for the request. If null, default options are applied to the
//     request.
//
//   operationContext:
//     An Microsoft.WindowsAzure.Storage.OperationContext object that represents the
//     context for the current operation.
//
// Remarks:
//     The Microsoft.WindowsAzure.Storage.Queue.CloudQueueMessage message passed in
//     will be populated with the pop receipt, message ID, and the insertion/expiration
//     time.
[DoesServiceRequest]
public virtual void AddMessage(CloudQueueMessage message, TimeSpan? timeToLive = null, TimeSpan? initialVisibilityDelay = null, QueueRequestOptions options = null, OperationContext operationContext = null);


Sfruttando il parametro initialVisibilityDelay possiamo dire per quanto tempo (dal momento dell’inserimento) l’elemento in coda sarà invisibile. A questo punto basta applicare un piccolo stratagemma per distribuire il carico di lavoro senza piegare il database come accadeva in precedenza:

  • L’elemento zero (il padre supremo) sarà invisibile sino a “DateTime.UtcNow.AddMinutes(5)” (=oppure altro numero di minuti) e l’orario risultante verrà salvato in una variabile che chiameremo per comodità “nextItemTime”

  • Ogni elemento figlio (ottenuto dalla query) verrà distanziato di un tempo X sommandolo al valore di nextItemTime.

  • In questo modo i messaggi inseriti nella coda verranno scodati in maniera sequenziale e l’intervallo tra uno ed il successivo sarà uguale.

Purtroppo non vi posso dire quanto vale “X” nel vostro caso. Per ottenere il valore più sensato possibile vi conviene attivare “Azure Application Insights” per monitorare i tempi della function (oltre che eventuali errori dovuti ad una nuova versione di codice)

Vi consiglio di utilizzare TraceWriter log all’interno delle vostre function per tracciare il più possibile le operazioni svolte. Nel mio caso ho applicato la seguente strategia:

  • log.info: Tracciare un messaggio di “servizio”

  • log.Warning: Se hai tempo fai un controllo manuale della situazione. Non ha dato errore, ma forse qualcosa non è andato

  • log.Error: posizionato all’interno dei try/catch per tracciare l’Exception subita ed eventuali valori (o informazioni) per test manuali successivi.

Grazie a questa modifica di codice (che è durata circa tre giorni di sviluppo) ora il sistema è tornato utilizzabile rendendo operazioni lunghe ed onerose veramente semplici. Ora basta una semplice chiamata per delegare il tutto in cloud senza ansie da timeout come in precedenza.