Looking for a generalized solution (for all sobjects/ all trigger-driven services) to this use case:
- After update trigger invoked; decides a callout via future is needed (could be related record DML too)
- Workflow field update causes trigger to refire. Per SFDC doc, last bullet, the trigger is presented with the original Trigger.old and decides the callout needs to be sent again (bad).
Trigger will execute in many use cases:
- From standard UI or VF pages (
AllOrNone = true
) - From Data Loader (
AllOrNone = false
) - From Bulk API (
AllOrNone = false
)
Although static variables can be used to control WF-iniated recursion in an AllOrNone = true
use case they don't work when AllOrNone = false
and 1+ records succeed in the batch and 1+ records fail. The retry (see second bullet in doc) will roll back the updates, pass the successes back through the trigger again but, as the static variables aren't rolled back, the trigger will do nothing - no callout will be made.
Obviously, I could get rid of the workflow field update and move into the trigger the field update but what happens when some admin inadvertently adds back a new workflow with field update? Any batches with AllOrNone=false
and partial errors will leave the database in an inconsistent state with respect to these callouts (or other trigger-driven DML).
This won't work in Use Case: AllOrNone=false with 1+ successes, 1+ failures in a batch
public class LeadTriggerHandler {
static set<ID> leadIdsAlreadySentToFuture = new set<ID>(); // recursion control
public void onAfterUpdate(Lead[] leads, map<ID,Lead> oldLeads) {
set<ID> leadIdsToCallout = new set<ID>();
for (Lead l: leads) {
Lead oldLead = oldLeads.get(l.Id);
if (l.Company != oldLead.Company &&
!leadIdsAlreadySentToFuture.contains(l.Id)) { // have we already done this?
leadIdsToCallout.add(l.Id);
leadIdsAlreadySentToFuture.add(l.Id);
}
}
}
@future
static void doCallout(set<ID> leadIds) {
// .. callout details not important
}
}
Ideas considered:
Option 1: Custom Transaction_State__c
Sobject to preserve state, but that doesn't need to be immediately cleared
In the trigger, once it decides it needs to callout, save in a custom Transaction_State__c
object with key = some application context + SobjectId + Transaction Id
to record whether the trigger has done its work already. Inspectable (via SOQL) in the trigger when running after a field update so acts like a rollback-able static variable in an AllOrNone = false
retry use case. Thus, in retry, the trigger's first execution queries and finds nothing in Transaction_State__c
for the context + row + transaction Id, sets the Sobject and when the WF field update comes around, the trigger bypasses the second callout because now there will be a row in Transaction_State__c
.
The transaction Id (which can be a static variable as we don't want this to roll back) could be the currentDateTimeInMs + running userId.
Drawbacks
- Costly, a DML call has to be made (in the first, pre-WF trigger invocation); a SOQL has to be made (twice - before and after the workflow)
- Eventually, the rows in this Sobject need to be deleted so some Schedulable needs to be written (minor, but a pain)
Option 2 Have workflows that unconditionally always set, via Field Update, an Is_WF_Processed__c = true
on each SObject.
Being an SObject field, it will get rolled back in the AllOrNone = false retry use case. However, the field needs to be cleared once the transaction is done and that yields another DML call in the after trigger with consequent new trigger firing
Drawbacks
- Cost of clearing the field
- Every object that has a trigger will need this workflow as a guard against the duplicate work. Triggers will always fire twice, even in transactions where the rule criteria for all other workflows on the Sobject evaluate to false.
- The duplicate trigger execution issue should be solved exclusively in apex (separation of concerns - you shouldn't have to orchestrate workflow to aid in the solution)
- It all has to be repeated for Process Builder
Is there a lighter-weight solution than option 1 (which I'm inclined to go with)?
Lengthy blog post with proofs and proposed solution