An insidious set of circumstances:
Starting condition:
- You have an afterUpdate trigger that if a condition when oldvalue changes to newValue, you want to do a callout
- You also have a workflow that updates the same object and that workflow’s entry criteria is satisfied when the for the same update event
Now, you might not know this, but Workflow field updates will cause the trigger to re-execute AND, the value of Trigger.old will be as it was when the record was initially updated. Here’s what it says from the well-thumbed Triggers and Order of Execution Apex Doc:
Trigger.old contains a version of the objects before the specific update that fired the trigger. However, there is an exception. When a record is updated and subsequently triggers a workflow rule field update, Trigger.old in the last update trigger won’t contain the version of the object immediately prior to the workflow update, but the object before the initial update was made
Thus, your future method will be called TWICE, and, if it is doing a callout, will callout twice. Here’s a simple proof:
Apex trigger
trigger LeadTrigger on Lead (before insert, before update, after insert, after update) { if (Trigger.isAfter && Trigger.isUpdate) new LeadTriggerHandler().onAfterUpdate(Trigger.new,Trigger.oldMap); }
Apex Trigger handler
public class LeadTriggerHandler { public void onAfterUpdate(Lead[] leads, map<ID,Lead> oldLeads) { for (Lead l: leads) { Lead oldLead = oldLeads.get(l.Id); if (l.Company != oldLead.Company) { System.debug(LoggingLevel.INFO,'company has changed from ' + oldLead.Company + 'to ' + l.Company + ' .. request an @future to dowork'); doCallout(l.Company); } } } @future static void doCallout(String company) { System.debug(LoggingLevel.INFO,'future method to do callout for ' + company); // .. callout details not important } }
Workflow
- Evaluation Criteria: Evaluate the rule when a record is created, and any time it’s edited to subsequently meet criteria
- Rule Criteria: If Lead.Company contains ‘Changed’
- Action: Field update Lead.Mobile to ‘650-555-1212’
Anonymous apex to demonstrate
Lead[] leads = new list<Lead> { new Lead(Company = 'Foo00', LastName = 'LName00'), new Lead(Company = 'Foo01', LastName = 'LName01') }; insert leads; leads[0].Company = 'Foo00Changed'; leads[1].Company = 'Foo01Changed'; update leads; // force future to execute in handler
Debug log(s)
Ack! the future fired four(4) times! We should only have had two (2) as we updated only two records.
|USER_INFO|[EXTERNAL]|00540000000wbFS|cropredy@gmail.com|Pacific Standard Time|GMT-08:00 |EXECUTION_STARTED // lead inserted -- details omitted ... // Lead update event Lead.Company value changes DML_BEGIN|[9]|Op:Update|Type:Lead|Rows:2 CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] USER_DEBUG|[7]|INFO|company has changed from Foo00to Foo00Changed .. request an @future to dowork USER_DEBUG|[7]|INFO|company has changed from Foo01to Foo01Changed .. request an @future to dowork CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] CODE_UNIT_STARTED|[EXTERNAL]|Workflow:Lead WF_RULE_EVAL_BEGIN|Assignment WF_SPOOL_ACTION_BEGIN|Assignment WF_ACTION|. WF_RULE_EVAL_END WF_RULE_EVAL_BEGIN|Workflow WF_CRITERIA_BEGIN|[Lead: LName00 00Q1W00001Jh9VR]|onUpdate - Set Field|01Q1W000000RGk3|ON_CREATE_OR_TRIGGERING_UPDATE|0 WF_RULE_FILTER|[Lead : Company contains Changed] WF_RULE_EVAL_VALUE|Foo00Changed WF_CRITERIA_END|true WF_CRITERIA_BEGIN|[Lead: LName01 00Q1W00001Jh9VS]|onUpdate - Set Field|01Q1W000000RGk3|ON_CREATE_OR_TRIGGERING_UPDATE|0 WF_RULE_FILTER|[Lead : Company contains Changed] WF_RULE_EVAL_VALUE|Foo01Changed WF_CRITERIA_END|true WF_SPOOL_ACTION_BEGIN|Workflow WF_FIELD_UPDATE|[Lead: LName00 00Q1W00001Jh9VR]|Field:Lead: Mobile|Value:650-555-1212|Id=04Y1W000000PfJV|CurrentRule:onUpdate - Set Field (Id=01Q1W000000RGk3) WF_FIELD_UPDATE|[Lead: LName01 00Q1W00001Jh9VS]|Field:Lead: Mobile|Value:650-555-1212|Id=04Y1W000000PfJV|CurrentRule:onUpdate - Set Field (Id=01Q1W000000RGk3) // Workflow updates the Leads with Field Update WF_ACTION| Field Update: 2; WF_RULE_EVAL_END // before/after triggers on Lead re-fire (expected) CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] // uh-oh, Trigger.old has values prior to the initial update DML, // not the values as of the after update conclusion USER_DEBUG|[7]|INFO|company has changed from Foo00to Foo00Changed .. request an @future to dowork USER_DEBUG|[7]|INFO|company has changed from Foo01to Foo01Changed .. request an @future to dowork CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9VR, 00Q1W00001Jh9VS] WF_ACTIONS_END| Field Update: 2; CODE_UNIT_FINISHED|Workflow:Lead DML_END|[9] CODE_UNIT_FINISHED|execute_anonymous_apex EXECUTION_FINISHED
Solution 1 (sounds good)
Just add a static recursion control variable to your handler
public class LeadTriggerHandler { static set<ID> leadIdsAlreadySentToFuture = new set<ID>(); // recursion control public void onAfterUpdate(Lead[] leads, map<ID,Lead> oldLeads) { for (Lead l: leads) { Lead oldLead = oldLeads.get(l.Id); if (l.Company != oldLead.Company && !leadIdsAlreadySentToFuture.contains(l.Id)) { // have we already done this? System.debug(LoggingLevel.INFO,'company has changed from ' + oldLead.Company + 'to ' + l.Company + ' .. request an @future to dowork'); doCallout(l.Company); leadIdsAlreadySentToFuture.add(l.Id); } } } @future static void doCallout(String company) { System.debug(LoggingLevel.INFO,'future method to do callout for ' + company); // .. callout details not important } }
This works as the debug log shows the future being called twice, once per Lead updated:
So, can I now work on my next JIRA ticket? Sorry ….
What if your Trigger/Handler is also invoked in a use case where partial success is allowed and one or more of the records fails to validate? AllOrNone = false can happen in many common use cases:
- Data Loader
- Any use of Apex Database.update(records,false); True also for the other Database.xxx methods.
- Bulk, SOAP, or REST APIs that either default AllOrNone to false or set explicitly if available.
Here, we run into another little-known SFDC feature of trigger retries in the AllOrNone = false (i.e. partial successes allowed) use case. This is documented in the Apex guide as:
Going back to the Triggers and Order of Execution, there’s one last tidbit as to why you can’t use static variables for recursion control in an AllOrNone = false use case:
When a DML call is made with partial success allowed, more than one attempt can be made to save the successful records if the initial attempt results in errors for some records. For example, an error can occur for a record when a user-validation rule fails. Triggers are fired during the first attempt and are fired again during subsequent attempts. Because these trigger invocations are part of the same transaction, static class variables that are accessed by the trigger aren’t reset. DML calls allow partial success when you set the allOrNone parameter of a Database DML method to false or when you call the SOAP API with default settings. For more details, see Bulk DML Exception Handling.
So, if you do a bulk update of two records, and one fails the validation rule, the static recursion control variable will be set on the first attempt, any @future calls are rolled back, and, when SFDC makes the second attempt on the non-failed record, the recursion control prevents the callout attempt from even happening so you end up with no callouts!
Let’s look at a proof:
Add a validation rule:
Website = 'www.failme.com'
Execute this code:
Lead[] leads = new list<Lead> { new Lead(Company = 'Foo00', LastName = 'LName00'), new Lead(Company = 'Foo01', LastName = 'LName01') }; insert leads; leads[0].Company = 'Foo00Changed'; leads[1].Company = 'Foo01Changed'; leads[1].Website = 'www.failme.com'; // force partial success by failing this in VR Database.SaveResult[] results = Database.update(leads,false); // allow partial success
Get this debug log
No future logs! Future never happened!
CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex // 1st time trigger is executed - both Leads passed: DML_BEGIN|[11]|Op:Update|Type:Lead|Rows:2 CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv, 00Q1W00001Jh9Vw] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv, 00Q1W00001Jh9Vw] CODE_UNIT_STARTED|[EXTERNAL]|Validation:Lead:00Q1W00001Jh9Vv // Validation rules execute VALIDATION_RULE|03d1W000000Tdvy|Coerce_failure VALIDATION_FORMULA|Website = 'www.failme.com'|Website=null VALIDATION_PASS CODE_UNIT_FINISHED|Validation:Lead:00Q1W00001Jh9Vv CODE_UNIT_STARTED|[EXTERNAL]|Validation:Lead:00Q1W00001Jh9Vw VALIDATION_RULE|03d1W000000Tdvy|Coerce_failure VALIDATION_FORMULA|Website = 'www.failme.com'|Website=www.failme.com // Fail the second Lead VALIDATION_FAIL CODE_UNIT_FINISHED|Validation:Lead:00Q1W00001Jh9Vw // After update sees only the first, successful, Lead; future requested, static vbl set CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] USER_DEBUG|[8]|INFO|company has changed from Foo00to Foo00Changed .. request an @future to dowork CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_STARTED|[EXTERNAL]|Workflow:Lead // Workflow executes , causes field update on first lead WF_RULE_EVAL_BEGIN|Workflow WF_CRITERIA_BEGIN|[Lead: LName00 00Q1W00001Jh9Vv]|onUpdate - Set Field|01Q1W000000RGk3|ON_CREATE_OR_TRIGGERING_UPDATE|0 WF_RULE_FILTER|[Lead : Company contains Changed] WF_RULE_EVAL_VALUE|Foo00Changed WF_CRITERIA_END|true WF_SPOOL_ACTION_BEGIN|Workflow WF_FIELD_UPDATE|[Lead: LName00 00Q1W00001Jh9Vv]|Field:Lead: Mobile|Value:650-555-1212|Id=04Y1W000000PfJV|CurrentRule:onUpdate - Set Field (Id=01Q1W000000RGk3) WF_ACTION| Field Update: 1; WF_RULE_EVAL_END // WF field update causes after trigger to re-execute (as expected) CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] // after trigger is NOP as recursion vbl says do nothing CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] WF_ACTIONS_END| Field Update: 1; CODE_UNIT_FINISHED|Workflow:Lead // SFDC retries the first record because AllOrNone=false; governor limits reset // But static variables are not reset CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_STARTED|[EXTERNAL]|Validation:Lead:00Q1W00001Jh9Vv // WF fires again, updates the first Lead but no callout done as recursion vbl prevents WF_RULE_EVAL_BEGIN|Workflow WF_CRITERIA_BEGIN|[Lead: LName00 00Q1W00001Jh9Vv]|onUpdate - Set Field|01Q1W000000RGk3|ON_CREATE_OR_TRIGGERING_UPDATE|0 WF_RULE_FILTER|[Lead : Company contains Changed] WF_RULE_EVAL_VALUE|Foo00Changed WF_CRITERIA_END|true WF_SPOOL_ACTION_BEGIN|Workflow WF_FIELD_UPDATE|[Lead: LName00 00Q1W00001Jh9Vv]|Field:Lead: Mobile|Value:650-555-1212|Id=04Y1W000000PfJV|CurrentRule:onUpdate - Set Field (Id=01Q1W000000RGk3) WF_ACTION| Field Update: 1; WF_RULE_EVAL_END CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event BeforeUpdate for [00Q1W00001Jh9Vv] // no callout request made in retry of first record CODE_UNIT_STARTED|[EXTERNAL]|01q1W000000Tdah|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] CODE_UNIT_FINISHED|LeadTrigger on Lead trigger event AfterUpdate for [00Q1W00001Jh9Vv] WF_ACTIONS_END| Field Update: 1; CODE_UNIT_FINISHED|Workflow:Lead DML_END|[11] CODE_UNIT_FINISHED|execute_anonymous_apex EXECUTION_FINISHED
So now what?
If we take the recursion static variable away, then the AllOrNone use case will still not pass – the future will get called twice on the successful record and never on the failed record.
... after the VR fails record[1] and before the WF executes USER_DEBUG|[8]|INFO|company has changed from Foo00 to Foo00Changed .. request an @future to dowork ... after the WF updates record[0] .. our original issue USER_DEBUG|[8]|INFO|company has changed from Foo00to Foo00Changed .. request an @future to dowork .. SFDC retries the successful records in trigger.new; skips the failed ones .. trigger re-executes as if none of the above ever happened USER_DEBUG|[8]|INFO|company has changed from Foo00 to Foo00Changed .. request an @future to dowork ... after the WF updates record[0] .. our original issue USER_DEBUG|[8]|INFO|company has changed from Foo00to Foo00Changed .. request an @future to dowork
Clearly, static variables can’t be used to control redundant callouts when workflows and AllOrNone = false are combined in the same use case.
Solution 2 (better, but fragile)
- Move the field updates out and put them in the before insert/update triggers.
- This way, the workflow will never force the trigger to re-execute with the original, start-of-transaction state of Trigger.old
- Hence, your doCallout logic will only execute once in the transaction
Go through your workflows that could update the Object where you are testing for make-a-callout conditions.
This is fragile because you or some colleague could add at some later time a new workflow+field update that causes the trigger’s callout-evaluating condition to be re-assessed and you’ll be making duplicate callouts again.
Solution 3 – what could it be?
Clearly, you need to have state that:
- Persists across the trigger – workflow field update – trigger sequence
- Is rolled back when SFDC retries in the AllorNone = false (partial success) use case
We’ve seen that static variables won’t work. Platform cache would not work as it isn’t rolled back in the AllOrNone = false scenario.
The only thing that meets both criteria would be Sobject fields. A general approach …
- Trigger sets the field in Trigger.new (or updates some other Sobject with a known key)
- Workflow field update is made, trigger re-executes. Using the values in trigger.new, looks to see if the work was already done and if yes, avoids asking to do it again
- If trigger is running in a AllOrNone = false use case, and a record fails in the batch, the update made in step 1 is rolled back by SFDC. Thus, the trigger re-requests the work, persists the request in an sobject, and even though the workflow will re-fire, the persisted sobject can be inspected on the second trigger re-fire and the dowork request skipped
Now, what would you save in that sobject field?
- Initially, I was tempted to save the sessionId (encrypted as a MAC digest) as a pseudo signal that the callout request was made. As an sobject, it would be rolled back in the AllOrNone-false with error use case. But, when the Bulk API is used, there is no sessionId — it is null.
- Next idea was to exploit lastModifiedDate and lastModifiedById and compare the running user and current time to see if the trigger is being used in the trigger-workflow+field update-trigger use case as a sort of pseudo state variable. This seems problematic for several reasons like time skew and concurrent transactions coming from the same user
- Another idea was an unconditional workflow field update to set a field called Is_WF_Update_in_Progress__c. Thus, the second time through the trigger, the code would inspect the Is_WF_Update_in_Progress__c in Trigger.new, say, “ha”, I’m being called in a workflow field update-induced trigger invocation and bypass the request to do a callout. But, then the new field would have to be cleared (another DML) and, we’d be unnecessarily forcing the trigger to re-execute even if no other workflow’s rule criteria were satisfied. This slows down performance. This is complicated and doesn’t scale well. Every SObjectType needs its own clone of this if involved in workflows + triggers that compare trigger.old to trigger.new
A Workable, albeit a bit heavyweight solution
Create a Custom Object Transaction_State__c
One relevant field:
- Name – This will be the transactionId
Create a Transaction Service class
public class TransactionService { /** * Meat of the TransactionService * * 1 - Set/get of transactionId * 2 - Track visitedIds by scopeKey to avoid recursion * 3 - Track whether a "context" is enabled or disabled - especially useful for testmethods to switch of async handling **/ @TestVisible private static String transactionId; /** * get(set)TransactionId - use to record some identifier for the transaction. Particularly useful for incoming REST calls * so methods can reference without having to pass around in arguments **/ public virtual String getTransactionId() { return transactionId == null ? transactionId = String.valueOf(System.currentTimeMillis()) + '_' + UserInfo.getName() : transactionId; } public virtual void setTransactionId(String txnId) { transactionId = txnId; } public virtual Boolean hasTransactionId() { return transactionId == null ? false : true; } private static map<String,Boolean> enablementByContext = new map<String,Boolean> (); /** * isDisabled (String context) - returns true if this context has been switched off * future enhancement - read from custom metadata to allow external (dis)(en)ablement **/ public virtual Boolean isDisabled(String context) { return enablementByContext.containsKey(context) ? !enablementByContext.get(context) // in map, return whether enabled or disabled : false; // no entry, hence enabled } /** * isEnabled (String context) - returns true if this context has been switched on or never entered * future enhancement - read from custom metadata to allow external (dis)(en)ablement **/ public virtual Boolean isEnabled(String context) { return enablementByContext.containsKey(context) ? enablementByContext.get(context) // in map, return whether enabled or disabled : true; // no entry, hence enabled } /** * setEnablement(String context, Boolean isEnabled) **/ public virtual void setEnablement(String context, Boolean isEnabled) { if (isEnabled == null) throw new TransactionService.TransactionServiceException('setEnablement isEnabled argument can not be null'); enablementByContext.put(context,isEnabled); } static ID txnStateIdAsProxyForStateTrust; // beacon to tell us if we can trust static variables /** * establishStateTrust - Transaction_State__c is an sobject * 1 - so, it is rolled back on allOrNone = false retry * 2 - hence we point at it with a static variable that isn't rolled back on retry * 3 - If the two don't agree, we know we are retrying and static map must be reset to empty **/ private void establishStateTrust() { if (txnStateIdAsProxyForStateTrust == null) { // no trust yet setup resetStateTrust(); } else { // if we have an sobject, has it been rolled back because we are in an AllOrNone = false // (partial success) SFDC-initiated retry use case on the "successes"? Transaction_State__c[] txnStates = [select Id, Name from Transaction_State__c where Id = : txnStateIdAsProxyForStateTrust]; if (txnStates.isEmpty()) { // static vbl points at sobject that has been rolled back resetStateTrust(); } else {} // if the static variable we established points at an existing Transaction_State__c, // that means we are not in an AllOrNone = false retry step and the static variables // maintaining state can be relied on. Thus, triggers re-executed // as part of a workflow/Process Builder can avoid repeating logic } } private void resetStateTrust(){ Transaction_State__c txnState = new Transaction_State__c(Name = transactionid); insert txnState; txnStateIdAsProxyForStateTrust = txnState.Id; clearVisitedCaches(); } /** * Map takes care of visited Ids by scopeKey and is valid up until the point that a retry * is detected; then map is cleared and we start afresh **/ static map<String,Set<ID>> visitedIdsThisTxnByScopeKey = new map<String,set<ID>> (); public virtual set<ID> getUnvisitedIdsThisTxn(String scopeKey, set<ID> proposedIds) { establishStateTrust(); if (visitedIdsThisTxnByScopeKey.containsKey(scopeKey)) { set<ID> unvisitedIds = new set<ID>(proposedIds); // start with proposedIds as unvisited unvisitedIds.removeAll(visitedIdsThisTxnByScopeKey.get(scopeKey)); // remove any Ids we've already seen visitedIdsThisTxnByScopeKey.get(scopeKey).addAll(proposedIds); // update visited set return unvisitedIds; } else { // new scopeKey, hence all ids are unvisited visitedIdsThisTxnByScopeKey.put(scopeKey,new set<ID>(proposedIds)); return proposedIds; } } /** * peekVisitedIdsThisTxn - Inspect visitedIDs this Transaction without affecting set (for a given scope key) **/ public virtual set<ID> getVisitedIdsThisTxn(String scopeKey) { return visitedIdsThisTxnByScopeKey.containsKey(scopeKey) ? visitedIdsThisTxnByScopeKey.get(scopeKey) : new set<ID>(); } /** * getVisitedIdsThisTxn - Inspect visitedIDs this Transaction without affecting set (all scope keys) **/ public virtual map<String,set<ID>> getVisitedIdsThisTxn() { return visitedIdsThisTxnByScopeKey; } /** * clearVisitedCache() - Clears specific visited ID cache **/ public virtual void clearVisitedCache(String scopeKey) { if (visitedIdsThisTxnByScopeKey.containsKey(scopekey)) visitedIdsThisTxnByScopeKey.get(scopekey).clear(); else throw new TransactionService.TransactionServiceException('Invalid scopeKey: ' + scopeKey + ' for clearVisitedCaches'); } /** * clearVisitedCaches() - Clears all visited ID caches; useful for testmethods **/ public virtual void clearVisitedCaches() { visitedIdsThisTxnByScopeKey.clear(); } }
Modify the triggerhandler code as follows
public class LeadTriggerHandler { public void onAfterUpdate(Lead[] leads, map<ID,Lead> oldLeads) { set<ID> unvisitedIds = TransactionService.getVisitedIdsThisContext('LeadDoFuture',oldLeads.keySet()); for (Lead l: leads) { Lead oldLead = oldLeads.get(l.Id); if (!unvisitedIds.contains(l.Id) && l.Company != oldLead.Company) { System.debug(LoggingLevel.INFO,'company has changed from ' + oldLead.Company + 'to ' + l.Company + ' .. request an @future to dowork'); doCallout(l.Company); } } } @future static void doCallout(String company) { System.debug(LoggingLevel.INFO,'future method to do callout for ' + company); // .. callout details not important } }
The triggerhandler asks the Transaction Service to get all unvisited Ids for some context scope. Behind the scenes, the TransactionService saves the Ids + context scope + TransactionId in the database, thus creating a persistent store for the AllOrNone = true use case and a rolback-able store for the AllOrNone = false use case.
Now, if you run an AllOrNone = true use case
Lead[] leads = new list<Lead> { new Lead(Company = 'Foo00', LastName = 'LName00'), new Lead(Company = 'Foo01', LastName = 'LName01') }; insert leads; leads[0].Company = 'Foo00Changed'; leads[1].Company = 'Foo01Changed'; update leads; // force future to execute in handler
You see the future is called twice, once per record
If you run in an AllOrNone = false use case
Lead[] leads = new list<Lead> { new Lead(Company = 'Foo00', LastName = 'LName00'), new Lead(Company = 'Foo01', LastName = 'LName01') }; insert leads; leads[0].Company = 'Foo00Changed'; leads[1].Company = 'Foo01Changed'; leads[1].Website = 'www.failme.com'; // force partial success by failing this in VR Database.SaveResult[] results = Database.update(leads,false); // allow partial success
You see the future is only called once for the record that does not fail validation rules
Hi Eric,
My thanks for such a thoughtful and interesting article that nicely sets the scene, shows the different solution attempts and finally poses a workable solution.
A question: what are you doing here to manage the clean up of your transaction state instances?
Phil —
First of all, I have since improved the solution (but not yet updated the blog post 🙁 ) as the Transaction_State__c sobject only needs a Name field with value = currentTimeInMilliseconds concatenated with the running user’s name + sessionId (if non-null)
As for cleaning up Transaction_State__c, I have a scheduled job that runs daily and deletes any objects older than 1 day
Eric
Hello!
Very nice solution to the same problem I’m having today. Would you mind posting your up to date solution? Thank you kindly!!!
Rita
Rita — I updated the post with my latest code. Hope it helps. This was a difficult problem to overcome (the replay of partial successes) but our org has used this code for over 15 months now without issue
Pingback: Please don't use static flags to control Apex Trigger recursion! · Nebula Consulting
One variation to this approach is to use a List Custom Setting instead of Custom Object. That way, you can use the getInstance() method as often as you want which doesn’t count as a SOQL call. You’ll still only have 1 DML each time the ‘state’ needs to be reset, but at most that could be 3 DML per transaction, and usually only 1.
Jeff — That is a good idea and I’m trying to remember why I didn’t do that as the soql benefit is obvious
I loved this blog. Thanks for your effort Eric ! . All exceptional scenarios I ran into in SFDC, I could find answers in this blog. Kudos !!