Monday, November 3, 2014

[Salesforce / Apex] Queueable interfaces - Unleash the async power!

The next Winter '15 release came with the new Queueable interface.

I wanted to go deep on this, and tried to apply its features to a real case.

If you are (like me) in a TLDR; state of mind, click here.

The main difference between future methods (remember the @future annotation? ) and queueable jobs are:

  • When you enqueue a new job you get a job id (that you can actually monitor)...you got it, like batch jobs or scheduled jobs!
  • You can enqueue a queueable job inside a queueable job (you cannot call a future method inside a future method!)
  • You can have complex Objects (such as SObjects or Apex Objects) in the job context (@future only supports primitive data types)

I wanted to show a pratical use case for this new feature.

Imagine you have a business flow in which you have to send a callout whenever a Case is closed. Let's assume the callout will be a REST POST method that accepts a json body with all the non-null Case fields that are filled exactly when the Case is closed (the endpoint of the service will be a simple RequestBin).

Using a future method we would pass the case ID to the job and so make a subsequent SOQL query: this is against the requirement to pass the fields we have in the case at the exact time of the update.
This may seem an exageration, but with big Orgs and hundreds of future methods in execution (due to system overload) future methods can be triggered after minutes and so the ticket state can be different from when the future was actually triggered.

For this implementation we will use a Callout__c Sobject with the following fields:

  • Case__c: master/detail on Case
  • Job_ID__c: external ID / unique / case sensitive, stores the job id
  • Send_on__c: date/time, when the callout has taken place
  • Duration__c: integer, milliseconds for the callout to be completed
  • Status__c: picklist, valued are Queued (default), OK (response 200), KO (response != 200) or Failed (exception)
  • Response__c: long text, stores the server response

Let's start with the trigger:

    trigger CaseQueueableTrigger on Case (after insert, after update) {

    List calloutsScheduled = new List();
    for(Integer i = 0; i < Trigger.new.size(); i++){
        if((Trigger.isInsert || 
           Trigger.new[i].Status != Trigger.old[i].Status)
            && Trigger.new[i].Status == 'Closed' )
        {
            ID jobID = System.enqueueJob(new CaseQueuebleJob(Trigger.new[i]));
            calloutsScheduled.add(new Callout__c(Job_ID__c = jobID, 
                                                 Case__c = Trigger.new[i].Id,
                                                Status__c = 'Queued'));
        }
    }
    if(calloutsScheduled.size()>0){
        insert calloutsScheduled;
    }
}

The code iterates through the trigger cases and if they are created as "Closed" or the Status field changes to "Closed" a new job is enqueued and a Callout__c object is created.
This way we always have evidence on the system that the callout has been fired.

Let's watch the job code

    public class CaseQueuebleJob implements Queueable, Database.AllowsCallouts {
    . . .
    }

The Database.AllowsCallouts allow to send a callout in the job.
Next thing is a simple constructor:

    /*
     * Case passed on class creation (the actual ticket from the Trigger)
     */
    private Case ticket{get;Set;}
    
    /*
     * Constructor
     */
    public CaseQueuebleJob(Case ticket){
        this.ticket = ticket;
    }

And this is the content of the interface's execute method:

    
     // Interface method. 
     // Creates the map of non-null Case fields, gets the Callout__c object
     // depending on current context JobID.
     // In case of failure, the job is queued again.
     
    public void execute(QueueableContext context) {
        //1 - creates the callout payload
        String reqBody = JSON.serialize(createFromCase(this.ticket));
        
        //2 - gets the already created Callout__c object
        Callout__c currentCallout = [Select Id, Status__c, Sent_on__c, Response__c, Case__c,
                                     Job_ID__c From Callout__c Where Job_ID__c = :context.getJobId()];
        
        //3 - starting time (to get Duration__c)
        Long start = System.now().getTime();
        
        //4 - tries to make the REST call
        try{
            Http h = new Http();
            HttpRequest request = new HttpRequest();
            request.setMethod('POST');
            //change this to another bin @ http://requestb.in
            request.setEndpoint('http://requestb.in/nigam7ni');
            request.setTimeout(60000);
            request.setBody(reqBody);
            HttpResponse response = h.send(request);
            
            //4a - Response OK
            if(response.getStatusCode() == 200){
                currentCallout.status__c = 'OK';
            //4b - Reponse KO
            }else{
                currentCallout.status__c = 'KO';
            }
            //4c - saves the response body
            currentCallout.Response__c = response.getBody();
        }catch(Exception e){
            //5 - callout failed (e.g. timeout)
            currentCallout.status__c = 'Failed';
            currentCallout.Response__c = e.getStackTraceString().replace('\n',' / ')+' - '+e.getMessage();
            
            //6 - it would have been cool to reschedule the job again :(
            /*
             * Apprently this cannot be done due to "Maximum callout depth has been reached." exception
            ID jobID = System.enqueueJob(new CaseQueuebleJob(this.ticket));
            Callout__c retry = new Callout__c(Job_ID__c = jobID, 
                                                 Case__c = this.ticket.Id,
                                                Status__c = 'Queued');
            insert retry;
            */
        }
        //7 - sets various info about the job
        currentCallout.Sent_on__c = System.now();
        currentCallout.Duration__c = system.now().getTime()-start;
        update currentCallout;
        
        //8 - created an Attachment with the request sent (it could be used to manually send it again with a bonification tool)
        Attachment att = new Attachment(Name = 'request.json', 
                                        Body = Blob.valueOf(reqBody), 
                                        ContentType='application/json',
                                       ParentId = currentCallout.Id);
        insert att;
    }

These are the steps:

  1. Creates the callout JSON payload to be sent (watch the method in the provided github repo) for more details (nothing more than a describe and a map)
  2. Gets the Callout__c object created by the trigger (and using the context's Job ID)
  3. Gets the starting time of the callout being executed (to calculate the duration)
  4. Tries to make the rest call
    1. Server responded with a 200 OK
    2. Server responded with a non OK status (e.g. 400, 500)
    3. Saves the response body in the Response__c field
  5. Callout failed, so fills the Respose__c field with the stacktrace of the exception (believe me this is super usefull when trying to get what happened, expecially when you have other triggers / code in the OK branch of the code)
  6. Unfortunately if you try to enqueue another job after a callout is done you get the following error Maximum callout depth has been reached., this is apparently not documented, but it should be related by the fact that you can have only 2 jobs in the queue chain, so apparently if you queue the same job you get this error.
    This way the job would have tried to enqueue another equal job for future execution.
  7. Sets time fields on the Callout__c object
  8. Finally creates an Attachment object with the JSON request done: this way it can be expected, knowing the precise state of the Case object sent and can be re-submitted using a re-submission tool that uses the same code (Batch job?).

This is a simple Callout__c object on CRM:

And this is an example request:

{
    "values": {
        "lastmodifiedbyid": "005w0000003fj35AAA",
        "businesshoursid": "01mw00000009wh7AAA",
        "engineeringreqnumber": "767145",
        "casenumber": "00001001",
        "product": "GC1060",
        "planid": "a05w000000Gpig7AAB",
        "ownerid": "005w0000003fj35AAA",
        "createddate": "2014-08-09T09:54:17.000Z",
        "origin": "Phone",
        "isescalated": false,
        "status": "Closed",
        "slaviolation": "Yes",
        "accountid": "001w0000019wqEIAAY",
        "systemmodstamp": "2014-11-03T19:33:31.000Z",
        "isdeleted": false,
        "priority": "High",
        "id": "500w000000fqNRaAAM",
        "lastmodifieddate": "2014-11-03T19:33:31.000Z",
        "isclosedoncreate": true,
        "createdbyid": "005w0000003fj35AAA",
        "contactid": "003w000001EetwEAAR",
        "type": "Electrical",
        "closeddate": "2013-06-20T18:59:51.000Z",
        "subject": "Performance inadequate for second consecutive week",
        "reason": "Performance",
        "potentialliability": "Yes",
        "isclosed": true
    }
}

The code and the related metadata is available on this GitHub repo.


No comments:

Post a Comment