Async Atarc Process


Real World Example : Geolocation for Airports

Business Story:There is an existing Airport object which has basic information about airports but its lacking longitude and latitude. When a new Airport record is created we need to find the longitude and latitude given an airport code and update the respective fields in the record so that it can be used later on.

Proposed: We will do this automation using an Atarc Process, we need to perform http callouts so that we can comunicate with an external system which can provide the require long-lat information. Using the async functionality so that the process is executed asynchronously fits perfectly in this scenario.

The solution has to be smart enough to avoid hitting Governor Limits around http callouts: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_callouts_timeouts.htm

Configuration

We are going to use google maps in order to retrieve latitude and longitude information, so we will store the maps endpoint in a custom label. Also we need the Airport sobject to test our code.

  1. Create a new Airport sobject:
    • Label: Airport, Plural Label: Airports, Object Name: Airport, Record Name: Airport Name, Data Type: Autonumber, Display Format: arpt-{0000000}, Deployment Status: Deployed, Allow Search: checked
  2. Create Airport fields:
    • Field Label: Full Name, Field Name: Full_Name, Required: True, Data Type: Text, Length: 255
    • Field Label: Code, Field Name: Code, Required: True, Data Type: Text, Length: 15
    • Field Label: Geolocation, Field Name: Geolocation, Latitude and Longitude Display Notation: Decimal, Decimal Places: 6
  3. Create new Custom Labels:
    • Short Description: MapsPlacesEndpoint, Name: MapsPlacesEndpoint, Value: https://maps.googleapis.com/maps/api/place/textsearch/json
    • Short Description: GoogleMapsKey, Name: GoogleMapsKey, Value: GOOGLE CREDENTIALS API KEY (create one here)
    • Short Description: GMinerRecordLimit, Name: GMinerRecordLimit, Value: 100
  4. Http callouts is needed, so Create a new Remote Site Settings:
    • Remote Site Name: maps_google_apis, Remote Site Url: https://maps.googleapis.com, Active: Checked

ATARC Process Settings

As usual, we need a record entry in the ATARC Process Settings custom metadata type, this time we need two records as we will be implementing Async Chaining:

Label
ATARC Process Setting Name
ApexHelperClassName
SObject
Event
IsActive
IsAsync
Order
DependsOnSuccess
DependsOnFailure
Debug
DebugLevel
breakIfError
Isolate
General Availability
AirportGeocodingMiner1
AirportGeocodingMiner1
AirportGeocodingMiner_AP
Airport__c
AfterInsert
true
true
1
true
DEBUG
false
false
true
AirportGeocodingMiner2
AirportGeocodingMiner2
AirportGeocodingMiner_AP
Airport__c
AfterInsert
true
true
2
AirportGeocodingMiner1
true
DEBUG
false
true
true

Code

After completing all the previous steps, now we have the Airport sobject where the data will be stored and two custom labels holding api keys and endpoints, now let's start with the apex helper class:


    /*
 * Created By : anyei
 * Created Date: 03/17/2020
 * ATARC Processes: AirportGeocodingMiner1, AirportGeocodingMiner2
 * Trigger Event: After Insert (only)
 * Unit Test AirportGeocodingMiner_AP_Test
 * Description: This atarc process is implementing one level depth chaining if more than 100 records are updated at once. 
 * The first atarc process 'AirportGeocodingMiner1' should process the first 100 records, then the remaining should handle by
 * 'AirportGeocodingMiner2' process, to make things interesting both are using the same apex helper AirportGeocodingMiner_AP.
 */ 
public class AirportGeocodingMiner_AP extends AsyncTriggerArc.AsyncTriggerArcProcessBase {
    static string mapsEndpoint = Label.MapsPlacesEndpoint;
    static string mapsApiKey = Label.GoogleMapsKey;
    static string finalEndpoint;
    static{
         finalEndpoint = mapsEndpoint + (mapsEndpoint.contains('\\?') ? '&': '?') + 'key='+mapsApiKey+'&type=airport';
    }
   
    public override object execute( AsyncTriggerArc.AsyncTriggerArcContext context){
        
        context.debug('Airport Geocoding Miner starts');
        map<id,Airport__c> airportMap = (map<id,Airport__c>)context.newMap;
        
        //checking if previous atarc process left some pending items to process for the second atarc process
        set<id> pendingToProcess = context.getProcessData('AirportGeocodingMiner1') == null ? new set<id>() : (set<id>) context.getProcessData('AirportGeocodingMiner1');
        
        //if this is the second atarc process and there's nothing to process then just exit
        if(context.getCurrentProcessName() == 'AirportGeocodingMiner2' && pendingToProcess.size() <= 0){
            context.debug('Nothing to do here..');
            return null;
        }
        
        list<Airport__c> toUpdate = new list<Airport__c>();
        
        //if we have pending to process, do those, else take it from the context
        set<id> airportsToProcess = pendingToProcess.size() > 0 ? pendingToProcess : airportMap.keyset();
        integer gminerRecordLimit = 100; //default
        try{
            gminerRecordLimit = integer.valueof(Label.GMinerRecordLimit);
        }catch(Exception err) {
            context.debug(err.getMessage());
        }
        integer c=0;
        for(id airportId : airportsToProcess){
            
            Airport__c airport = airportMap.get(airportId);
            
            //https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_callouts_timeouts.htm
            //apex transactions has 100 callout limits, so if by any chance the trigger sends more than 100 records we must chunk this
            if(c == gminerRecordLimit) {
                pendingToProcess.add(airport.id);
                continue;
            }
            
           string jsonResult = callout(finalEndpoint + '&query='+airport.code__c+EncodingUtil.urlEncode(' '+airport.full_name__c, 'UTF-8'), null, null);
           context.debug(jsonResult);
           
           map<string, object> geolocalized = (map<string, object>) JSON.deserializeUntyped(jsonresult);
           if(geolocalized.containsKey('results') && geolocalized.get('results') instanceof list<object>){
           		list<object> resultList = (list<object>)geolocalized.get('results');
                map<string, object> resultsMap = resultList.size() > 0 && resultList[0] instanceof map<string, object> ? (map<string, object>)resultList[0]  :new map<string, object>();
                
                if(resultsMap.containsKey('geometry') && resultsmap.get('geometry') instanceof map<string, object>){
                        map<string, object> geometry = (map<string, object>) resultsmap.get('geometry');
                            if(geometry.containsKey('location') && geometry.get('location') instanceof map<string, object>){
                                map<string, object> location =(map<string, object>) geometry.get('location');
                                string latStr = string.valueof(location.get('lat'));
                                string lngStr = string.valueof(location.get('lng'));
                                
                                //To Update only the fields we want
                                toUpdate.add(new Airport__c(
                                    Id = airportId,
                                    Geolocation__Latitude__s = decimal.valueof(latStr),
                                    Geolocation__Longitude__s = decimal.valueof(lngStr)
                                ));
                            }
                }
           }
           
          c++; 
        }
        if(toUpdate.size() > 0) update toUpdate;
        
        //sending the pending chunk to the next process
        //the first atarc process 'AirportGeocodingMiner1' should be sending  
        return pendingToProcess;
    }
    
    public static string callout(string endpoint, string method, string body){
        string result = null;
        httpRequest req = new HttpRequest();
        req.setEndpoint(endpoint);        
        if(!string.isblank(body)) req.setBody(body);
        if(string.isblank(method)) method = 'GET';
        req.setMethod(method);
        Http client = new Http();
        httpResponse resp = null;
        resp = client.send(req);
        
        if(resp != null) result = resp.getBody();
        
        return result;
        
    }

}


trigger ATARCAirportTrigger on Airport__c (before insert, before update, before delete, after insert, after update, after delete, after undelete) {
    
    new AsyncTriggerArc().start();

}
  

Two atarc processes are leveraging the same apex helper class, even if they are sharing common behaviors they are a little different. Let's break down what's going on:

Now the development is pretty much complete, you can test it out by creating a new Airport__c record, provide a real airport code and full name, you could use 'Addison Airport' in the full name and ADS as code. The result should be latitude 32.969972 and longitude 96.83646700000001.

Try to create multiple records at the same time to see the behavior of the code:


Expected Results:

The expected result is that the two records are updated propertly, and looking to the developer console logs, you should see two instance of a queueable wrapper running, each should have process a single record.