LinkedIn

Transaction Finalizers for Asynchronous Apex Jobs

Transaction Finalizers are Generally Available from Summer 21 release. Let us see the features and how to implement Transaction finalizers in this blog post.



1. What is Transaction finalizers?

The Transaction Finalizers feature enables you to attach actions, using the System.Finalizer interface, to asynchronous Apex jobs that uses the Queueable framework. The logic associated with Finalizer implementation will be executed once the asynchronous execution finishes. 

2. Methods available for Finalizer Interface

We can get below details of the executed job process in the finalizer Implementation:



3. Features of Transaction Finalizers



4. Implementation Details

Use Case

We want to execute 2 operations:

1. When ever a new contact is getting created/updated with Country field value, parent Account's Account country field should be appended with new country

If Account ABC has 2 contact - Contact X with country India and Contact Y with country France, the Account Country in Account ABC should be updated as India,France

2. Each country has an associated Admin group. When ever a country is added to an account, the Account owner should be added as a group member to that Country Admin Group.

Solution Flow




Implementation Details

Trigger

*Code does not follow trigger best practices of handler implementation

/*******************************
* Contact Trigger to update Account Country from Contact country field
*
* ********************************/
trigger populateAcccountrycontactqueue on Contact (after insert,after update) {
Set<id> accIds = new Set<id>();
for(Contact con : Trigger.new){
if(Trigger.isInsert || (Trigger.isUpdate&&con.country__c != Trigger.oldmap.get(con.id).Country__c)){
accIds.add(con.accountId);
}
}
if(accIds.size()>0){
PopulateAccountCountryQueue myq = new PopulateAccountCountryQueue(accIds);
ID jobID = System.enqueueJob(myq);
}
}


Queueable Method to Update Account country

/*******************************
* Class to update Account Country from Contact country field
*
* ********************************/
public class PopulateAccountCountryQueue implements Queueable{
private Set<id> accids ;
public PopulateAccountCountryQueue(Set<id> accids) {
this.accids = accids;
}
public void execute(QueueableContext context) {
try{
//Attahcing finalizer
FinalizerImplementation finalImpln = new FinalizerImplementation(accIds,true,true,'PopulateAccountCountry');
System.attachFinalizer(finalImpln);
if(accIds.size()>0){
//Query and get All contacts
Map<id,String> accidtoContactsMap = new Map<id,String>();
for(Contact con : [Select id, Country__c,accountID from Contact where accountid IN :accIds] ) {
if(accidtoContactsMap.get(con.accountid) != null && !accidtoContactsMap.get(con.accountid).contains(con.Country__c)){
String cntry = accidtoContactsMap.get(con.accountid)+','+con.Country__c;
accidtoContactsMap.remove(con.accountid);
accidtoContactsMap.put(con.accountid,cntry);
}
else{
accidtoContactsMap.put(con.accountID, con.Country__c);
}
}
//Create list of accounts to be updated
List<Account> acctoupd = new List<Account>();
for(Id accid : accidtoContactsMap.keyset()){
acctoupd.add(new Account(id=accid, Account_Country__c =accidtoContactsMap.get(accid)) );
}
if(acctoupd.size()>0){
update acctoupd;
}
}
}catch(Exception exc){
System.debug('Exception:'+exc.getMessage());
}
}

}

We need to use below code to Attach Finalizer to a Job:
//Attahcing finalizer
FinalizerImplementation finalImpln = new FinalizerImplementation(accIds,true,true,'PopulateAccountCountry');
System.attachFinalizer(finalImpln);

Finalizer Implementation

/*******************************
* Finalizer Implementation
*
* ********************************/
public with sharing class FinalizerImplementation implements Finalizer {
public Set<ID> recordIds;
public Boolean retry;
public boolean logException;
public String process;
//Constructor
public FinalizerImplementation(set<ID> recIds, Boolean retry, Boolean log, String process){
this.recordIds= recIds;
this.retry = retry;
this.logException = log;
this.process = process;
}
public void execute(FinalizerContext context){
Id parentQueueableJobId = context.getAsyncApexJobId();
System.debug(context.getAsyncApexJobId());
System.debug(context.getRequestId());
System.debug(context.getResult());
System.debug(context.getException());
//Check Apex Job Status
switch on context.getResult(){
when SUCCESS {
System.debug('Apex Job ID: ' + parentQueueableJobId + ' Succeeded');
//Execute another batch, @future or queuable method
//Call another queuable method to add Account owner to country grp,
//In normal flow it can cause mixed dml exception.But this approach will avoid that
switch on process {
when 'PopulateAccountCountry' {
System.enqueueJob(new addAccountOwnerToGroup(recordIds));
}
}
}
//On Exception
when UNHANDLED_EXCEPTION {
System.Debug('OH NOES! (Job ID: ' + parentQueueableJobId +
'): FAILED! with error: ' +
context.getException());
//retry logic
if(retry){
//Check for process type
switch on process {
when 'PopulateAccountCountry' {
System.enqueueJob(new PopulateAccountCountryQueue(recordIds));
}
//we can add additional processing here
when 'addAccountOwnerToGroup' {
System.enqueueJob(new addAccountOwnerToGroup(recordIds));
}
}
}
//Log Exception
if(logException){
ExceptionUtil.logException('Contact',process,parentQueueableJobId,context.getException().getMessage());
}
}
}
}
}


Quauable Job to Add Account Owner to Group

/*******************************
* Apex Job to Add Account Owner To Group
*
* ********************************/
public with sharing class addAccountOwnerToGroup implements Queueable{
private Set<id> accids ;
public addAccountOwnerToGroup(Set<id> accids) {
this.accids = accids;
}
public void execute(QueueableContext context) {
try{
FinalizerImplementation finalImpln = new FinalizerImplementation(accIds,true,true,'addAccountOwnerToGroup');
System.attachFinalizer(finalImpln);
//Get group Details
Map<String,id> countryToGrpMap = new Map<String,id>();
for(Group gp : [SELECT id,Name FROM Group WHERE NaME LIKE '%Admins%']){
countryToGrpMap.put(gp.Name.remove('Admins'),gp.id);
}
List<GroupMember> groupMemberList =new List<GroupMember>();
if(accIds.size()>0){
for(Account acc : [SELECT id,Account_Country__c,name,ownerID FROM Account WHERE id IN :accids]){
List<String> countries = acc.Account_Country__c.split(',');
for(String cntry :countries ){
//Add Owner to Group
groupMemberList.add(new GroupMember(GroupId=countryToGrpMap.get(cntry),UserOrGroupId =acc.OwnerID));
}
}

if(groupMemberList.size()>0){
insert groupMemberList;
}
}
}catch(Exception exc){
System.debug('Exception:'+exc.getMessage());
}
}
}


Result - Success

1. Account Country Updated




2. Owner added

Result - Failure

1. Log Exception


2. Retry

You can see job getting executed again:




5. Advantages of this Approach

1. Same Finalizer is attached to Both Account Coutry update and Group Member Addition. So Code Reusability exists

2. If we try to Insert Group member in the same transaction it can cause Mixed DML Exception since Group Member is set up object. So here we are able to run this in a seperate transaction by executing Group Member addition from Finalizer as a seperate call.

3. Since Finalizer Execution is in a seperate transaction, we get seperate set of Govornor limits.

4. On failure we can do retry and Logging/Notifying users.

References:

1. https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_transaction_finalizers.htm

2. https://developer.salesforce.com/blogs/2020/01/learn-moar-in-spring-20-introducing-transaction-finalizers.html

Comments

  1. Thanks a lot Meera for this wonderful post. Highly influenced by your way of explaining in the simplest way.

    ReplyDelete
  2. It's awesome meera , thanks for all your effort 👍

    ReplyDelete

Post a Comment

Popular posts from this blog

Subscribing to Salesforce Platform Events using External Java Client - CometD

Send Data from Salesforce to Data Cloud using Ingestion API and Flow

How to develop reusable Invocable Apex methods for Flows