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.
-
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
-
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
-
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
-
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:
- Async Feature, both are using this feature which is actually a configuration part. They will behave as queueable so they will run in the background, whenever Salesforce has resources available to execute them it will.
- No Isolation, the first Atarc Process, 'AirportGeocodingMiner1', doesn't have this flag checked in the configuration which means that any other Atarc Process executed after may share the same transaction.
- Isolation, the second Atarc Process, 'AirportGeocodingMiner2' does use Isolation. Even if it could share the same transaction as the first one, what is happening is that its using the first one as backdoor so that it can start its own Async context.
- Async Chaining, its possible when the previous Atarc Process is not doing Isolation. The previous example shows how Async Chaining can be implemented reusing the same apex helper class when both atarc processes have common logic.
- Process Dependency, to make sure 'AirportGeocodingMiner1' ran successfully before 'AirportGeocodingMiner2' the configuration field DependsOnSuccess has been populated for 'AirportGeocodingMiner2'.
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:
- Update the custom label 'GMinerRecordLimit' to 2.
-
Execute the following code using the anonymous console:
insert new List<Airport__c> { new Airport__c(full_name__c='Kodiak Airport',Code__c='ADQ'), new Airport__c(full_name__c='Andrews', Code__c='ADR') };
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.