2

Recently we moved our logic in our trigger to be asynchronous and now the helper method that the triggers call looks something like this (specifics obfuscated since it's company code):

global static void triggerHelper(/*parms*/)
{
    ClassOfHelper context = new ClassOfHelper(/*Constructer Parms*/);
    System.enqueueJob(context);
}

We hit an interesting issue however in our managed package. If a customer has an asynchronous process that updates an object which also has a workflow which makes a subsequent update we will hit a Governor limit "Too many queueable jobs added to the queue: 2" since we started on an asynchronous process and workflows triggered the triggers a second time.

There are a couple ways I've been thinking of approaching this problem

  1. use a singleton however the current code is expecting the transaction to all be the same object type (like account) so it would be a major change to move them all to one singleton.
  2. check to see if we are at the limit and if we are then go back to the old logic however that would make the code vulnerable to timing out due to upstream issues again.
  3. Expand our global methods to give customers control over when our code is called. In complicated cases like this one, those customers could customize solutions to their needs and it would give up a bigger toolbox for workarounds for future bugs but it could also be opening pandora's box.

It seems like this is a problem that other people have probably ran into as well so I'm wondering if there is a cleaner way to solve this?

Thanks in advance.

2 Answers 2

2

What I sometimes do is use a platform event to get out of the context and then I can avoid a queueable altogether.

I define the platform event (normally just with a payload var that is a serialized map with all the params.

Then, fire the event and in the event handler, unpack the event which has these params:

className
callableMethodName
callableParams

Here is my version:

public static void enqueueCallable(String className,String methodName,Map<String, Object> params) {
  Map<String, String> payload = new Map<String, String>{
    'className' => className,
    'methodName' => methodName,
    'params' => JSON.serialize(params)
  };
  Async_Message__e[] messages = new List<Async_Message__e>{ new Async_Message__e(Type__c = type, Payload__c = payload)};
  Database.SaveResult[] results = EventBus.publish(messages);
}

You then handle the async message in the same class here - and call your original callable helper.

public static void handleAsyncMessage(Async_Message__e message) {
  try {
    switch on message.Type__c {
      when 'Log' {
        System.debug('ASYNC LOG ' + message.Payload__c);
        System.debug('SESSION ID ' + UserInfo.getSessionId());
      }
      when 'Callable' {
        Map<String, Object> callableParams = (Map<String, Object>) JSON.deserializeUntyped(  message.Payload__c );
        String className = (String) callableParams.get('className');
        String methodName = (String) callableParams.get('methodName');
        String params = (String) callableParams.get('params');
        Map<String, Object> paramsMap = (Map<String, Object>) JSON.deserializeUntyped( params );
        //make the call!
        Callable someClass = (Callable) Type.forName(className).newInstance();
        someClass.call(methodName, paramsMap);
      }
    }
  } catch (Exception e) {
    System.debug('SOLIANT EXCEPTION: ' +'Line ' +e.getLineNumber() +' ' +e.getMessage());
  }
}

The whole thing is a called from you initial trigger helper which implements callable

Here's the callable handler in the initial trigger helper:

// Dispatch actual methods
public Object call(String action, Map<String, Object> args) {
  switch on action {
    when 'yourMethod' {
      String param1 = (String)args.get('param1');
      String param2 = (String)args.get('param2');
      yourMethod(param1,param2);
      return null;
    }
    when else {
     throw new ExtensionMalformedCallException('Method not implemented');
    }
  }
}

Finally, you call like this:

AsyncActionUtil.enqueueCallable('YourCallable', 'yourMethod',  
  new Map<String,Object>{'param1'=>'hello','param2'=>'world'}
);
7
  • N.B. the running user of the trigger will be Automated Process unless you configure otherwise; thus sharing rules won't be respected; the Appleman advanced async 4th edition pattern (while it has some different issues) respects initiating user context by falling back to schedulable when queueable not available.
    – cropredy
    Commented Mar 23, 2021 at 18:43
  • True... but triggers don't respect sharing rules either. Commented Mar 23, 2021 at 18:46
  • 1
    but won't the trigger-called helper WITH SHARING classes respect sharing rules (when running user is not Automated Process)?
    – cropredy
    Commented Mar 23, 2021 at 18:48
  • 2
    Yes, I was just checking that ;) and yes, they do... however, with triggers, you normally want all records to be in the net. Imagine if you had a trigger that, when run on a batch import, updated all the records, but when run by a user, only updated some of the records. I can't think of a single time when anyone would want that. Commented Mar 23, 2021 at 18:49
  • Thanks, I'm compiling options at the moment to present to the team and will include this. At least right now I think the team would be hesitant to commit to something like this since it would a lot more effort than we want to spend on this product.
    – J. Larson
    Commented Mar 23, 2021 at 21:05
1

If you have the flexibility to mark the records that require processing in some way you can then indirectly use a scheduled batch to deal with the end results of the over-all transaction/session.

We do this by having two Datetime "timestamp" fields, LastProcessed__c and LastUpdated__c and a Checkbox formula field NeedsProcessing__c set as NOT(ISBLANK(LastUpdated__c)) && (ISBLANK(LastProcessed__c) || LastProcessed__c < LastUpdated__c).

To summarise how we deal with it:

  1. To take the processing "outside the current transaction", we use an "after commit" platform event (apparently there can be some cases where these can fail to be delivered but this hasn't caused any of our customers issues at this point).
  2. We have a trigger-based platform event subscriber that processes the events, discarding duplicates and determining whether or not a batch execution should be started (is there one already running or not) and if one should be, it gets submitted. (This is open to race conditions of course.)
  3. In "start" the batch again checks if there's already one going and, if so, returns an empty query locator (e.g. find records where Id is NULL). Note that you are guaranteed that only one batch instance will be executing the start method at any one time, so this avoids race conditions.
  4. The batch processes all records where NeedsProcessing__c is true, and sets LastProcessed__c to the time at which the batch was started when the record(s) are successfully dealt with.
  5. At the end of the batch, if a COUNT for the records with NeedsProcessing__c as true is non-zero, the batch can be re-executed.

There's lots of nuance and detail you need to deal with, but that's basically how we address it. You can find more on the technique by searching for "adaptive batch" on SFSE.

2
  • Thanks, I'm compiling options at the moment to present to the team and will include this. At least right now I think the team would be hesitant to commit to something like this since it would a lot more effort than we want to spend on this product.
    – J. Larson
    Commented Mar 23, 2021 at 21:05
  • Fair enough. We use this pattern via a generic base class and specific specialisations for several different purposes, so for us it makes sense.
    – Phil W
    Commented Mar 24, 2021 at 3:59

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .