function readOnly(count){ }
Starting November 20, the site will be set to read-only. On December 4, 2023,
forum discussions will move to the Trailblazer Community.
+ Start a Discussion
jhartjhart 

Merge triggers: a maze of twisty little passages, all alike (and buggy?)

Some quick background on merges and Apex triggers:

When a User merges Leads or Contacts, the merge losers are deleted and the
merge winners are updated with the fields chosen by the User in the merge UI.

The only way to detect a merge in Apex triggers is to trigger "after delete" on
the loser records and check them for the "MasterRecordId" field.  If present,
the record being deleted is a merge loser, and the "MasterRecordId" points to
the merge winner.

(this is all covered in the docs )

As stated in the docs, the losers are deleted before the merge winner is
updated with the fields chosen by the User in the UI.

So, let's say that I merge two Leads: Lead A ("a@test.com") and Lead B
("b@test.com").  In the UI I choose Lead A as the master record (winner), but
in the "decide between conflicting fields" UI I choose "b@test.com" as the
Email address to use for the winner.

Two DML ops happen:

 

DELETE loser (via merge)
Trigger.old = { LastName = "B", Email = "b@test.com" }

UPDATE winner (via merge)
Trigger.old = { LastName = "A", Email = "a@test.com" }
Trigger.new = { LastName = "A", Email = "b@test.com" }


However, if we update the winner during the loser delete trigger (the only time
we can detect a merge, remember) ... then something buggy happens.

Our application does exactly this, by detecting merges and copying the loser's

Email address into a custom "OtherEmails" field of the winner.  (this isn't just
arbitrary, there's a good reason for it).


So, during the "DELETE loser" trigger, we update the winner like so:

DELETE loser (via merge)
Trigger.old = { LastName = "B", Email = "b@test.com" }
{
// our custom trigger code
Lead winner = [select Id, Email, OtherEmails from Lead where Id = '<winnerId>']
winner.OtherEmails += loser.Email
update winner;
// this update of course fires triggers too, which would look like this:
UPDATE winner (via standard update)
Trigger.old = { LastName = "A", Email = "a@test.com", OtherEmails = null }
Trigger.new = { LastName = "A", Email = "a@test.com", OtherEmails = "b@test.com" }
}

 

The bug happens in the merge-driven winner update, where SFDC should be
applying the fields chosen by the User during conflict resolution.

The fields chosen by the User are simply gone.  They never get updated into the
winner.  Instead, an update fires that looks like this:

UPDATE winner (via merge)
Trigger.old = { LastName = "A", Email = "a@test.com", OtherEmails = null }
Trigger.new = { LastName = "A", Email = "a@test.com", OtherEmails = "b@test.com" }

 
The User's choice of "Email = b@test.com" is simply gone ... instead this
merge-driven update is a duplicate of the update that happen in the loser's
delete trigger.


What do I expect to happen?

This is a tricky situation, hence the title of this post.  With the present
order of operations - with the loser delete happening before the winner update,
and with the merge only being detectable in the loser delete, I can't think of
any good way to resolve conflicts between trigger-driven winner updates and the
user-selected winner updates.  A couple other changes may fix the issue:

1.  Update the winner before deleting the loser.

This way, custom merge logic (in loser-delete triggers) would be working with a
Winner that's already been updated with the User-selected fields.

Of course, this is a breaking change for implementations that rely on the
current behavior (though I don't see how they could), and there are probably
good reasons for the current order of operations that I can't think of but
which are obvious to SFDC's devs.

2.  Provide an actual "after merge" trigger that provides the losers & winners at the same time.

This "after merge" trigger would be just like an "after update" trigger (ie,
Trigger.old/new contain the pre- and post-update state of the Winners), plus a
new contact variable Trigger.mergeLosers that contains what you would expect.

 

 

 

Salesforce support - i have created case 05650893 to track this issue.

jhartjhart


A side note that doesn't belong in the main post:

If you try to replicate this behavior directly within Apex Code - in
a testMethod, for example - the observed behavior is actually different than if
you do the merge from the UI.

There could be some way in code to mimic the User's choice in the merge UI that works better, but here's how I tried to do it:

Lead leadA = new Lead(LastName = 'A', Email = 'a@test.com');
Lead leadB = new Lead(LastName = 'B', Email = 'b@test.com');
insert new Lead[] { leadA, leadB };

leadA.Email = 'b@test.com'; // try to simulate selection User choice of 'b@test.com' in UI
merge leadA leadB;

 

The first set of operations (the DELETE loser trigger, and our custom code that
updates the winner) are exactly the same.

However, the final update is another beast entirely:

UPDATE winner (via merge)
Trigger.old = { LastName = "A", Email = "a@test.com", OtherEmails = "b@test.com" }
Trigger.new = { LastName = "A", Email = "b@test.com", OtherEmails = "b@test.com" }

 

So here we see the User's selection isn't discarded, but is instead applied to
the winner as updated in our custom code (Trigger.old = our custom code output).

I'm not sure that this behavior is perfect (as stated above, there may be no
"right" answer in the current order of operations), but it's better than what
happens in the UI ...  and it's confusing, because how can the Apex code even
try to emulate the UI for this case?


SteveBowerSteveBower

Interesting problem... seems like the UI must be keeping it's own instance of a Lead somewhere which it's waiting to update when the Delete is done.  But, since you've issued your own update I winder if it does a time comparison, realize that it's got an old version and do something different than simply overwriting the newer version.  It's wierd that it would re-issue *your* update, after all, it knew nothing of the otherMail field.  It leads me to wonder if it's doing some sort of field by field checking?

 

Question: does anything change if in the select in your after delete trigger you don't include the Email field?   I'm wondering if the UI would then feel free to update that field properly?

 

Best, Steve.

 

p.s. Shame we can't just xyzzy or plugh.

jhartjhart

That is an interesting question .. however we need to query the Email and OtherEmails field of the winner so that our merging of the fields works properly (the example above doesn't show all the details of our code, although with the data given it behaves exactly as above).

 

I'm not planning on diving into this much further b/c what salesforce is doing almost doesn't matter - as noted above, I don't think there is a "right answer" given the current order of operations.

 

We could workaround this by, in the Loser delete trigger, stashing our data into a separate custom field "MergeEmails" (which in all other situations would be unused).  The final winner update would then (I believe) have the correct user-chosen fields, and we could then un-stash the data from the MergeEmails field and do what we think is best with all the data at hand.  That seems rather baroque, however - essentially creating our own "after merge" framework on a field-by-field basis.

SteveBowerSteveBower

I agree that (assuming you're correct about your investigation), it seems like a flaw.  Rather than "cache" all your changes in extra fields you might be able to issue your update in a @future call instead.  Let the Merge's update finish, and then your update would eventually be done.  Not a solution per se, but an easier way to deal with the temporary storage issue assuming the time factor would be ok.

 

Please update if you find a solution or if it's an acknowledged bug, I'm curious.  Best, Steve.

jhartjhart

Using @future is a good idea, Steve.  It's probably safe to assume that the merge's winner update would complete before the @future method succeeds - after all the merge's winner update is part of the same transaction as the merge's loser delete, and the @future method shouldn't even queue until the transaction completes successfully.

jhartjhart

Response from salesforce partner support has arrived.  I'm told the current order of operations is "as designed" and, if I want it changed, I should post an idea on the IdeaExchange.  I don't have much luck with the IdeaExchange, so I'll skip that step.

 

 

Edit: the support engineer went ahead & posted to the IdeaExchange.

GoForceGoGoForceGo

Here is a workaround I am using and it works:

 

The sequence of events during merge from documentation with my comment for workaround.

 

1. The before delete trigger fires.
2. The system deletes the necessary records due to the merge, assigns new parent records to the child records, and sets the
MasterRecordId field on the deleted records.
3. The after delete trigger fires ---> make a list of masterRecordIds and stash it away in a static variable in an Apex class.
4. The system does the specific updates required for the master record. Normal update triggers apply.  ---> write a before update trigger here set the otherEmails.  In this trigger, get the stashed masterRecordIds and check if it exists  - essentially you are checking if  this trigger is being fired due to a merge.  In this trigger, look at Trigger.Old and copy the e-mail field to otherEmails in Trigger.New.

 

 

jhartjhart

That's a good workaround.  My idea above to use a custom, otherwise useless SObject field is a clutzier way of doing what you're doing with static vars.  The fact that static Apex vars are transaction-local storage is really useful sometimes =)

 

I implemented the @future workaround, but it's causing some heartache (can't call @future from @future, and some of our customers have installed packages that do merges in @future calls), so I'll probably switch to your workaround.  Thanks!

GoForceGoGoForceGo

Yes indeed. Stashing in static variables is helpful. I had several use case where I used it. In one of them,. If a lead is being deleted in a non-merge operation, I want to delete the child records pointing to this lead via a lookup.. In the after delete trigger, where I know whether a lead is being deleted due to merge or not, it is too late -- the child records don't point to this lead anymore.  So in the  before delete lead, I can make a list of those child records and stash them, without knowing whether they are part of merge. In after delete, I can get this stashed list and pick out the ones which are not part of merge and delete them.

 

 

 

 

jhartjhart

In that situation, wouldn't the child records auto-delete?  (unless you mean "child" logically, rather than specifically master:child).

 

Speaking of logically master-child relationships that (for whatever reason) have to be declared via a normal lookup field rather than a master:child relationship, we've leveraged another platform feature to implement our own cascading deletes.

If you have 40,000 "child" records all pointing at the same "master" record, and you delete that master record, execution limits will prevent you from deleting all those children.

However, when you delete a pointed-at record, all lookup fields that point at it are immediately NULL'd by the platform (for free).

So you can have a garbage collection routine that regularly sweeps the "child" table and deletes all records whose "parent" pointer is null.

We use this type of garbage collection in a couple different spots, it's quite useful & simple to understand.


GoForceGoGoForceGo

I meant logical child, not SFDC detail. SDFC does not allow Lead to be master in a master-detail - I don't know the exact reasons. 

 

Yes, a batch apex garbage collector would the work with the added benefit of avoiding gov. limits.

 

If I wasn't worried about gov. limits, in the after delete trigger, I could search for all childs that have NULL. I did not want to do that, because if the user had manually done these for some other childs for some other reason, I didn't want to sweep them in a delete.

 

 

jhartjhart

I understand it might not fit into your situation - just mentioning it as a useful platform interaction, as a thank-you for the static stash idea =)

GoForceGoGoForceGo

The code I mentioned above does get complicated and I am wondering if I should write a garbage collector.

 

Are you an ISV? If so, I have this question:

 

Seems you implemented the garbage collector as a batch program which needs to be scheduled. Are you asking your end customers to schedule it manually using the UI? Or are you calling system.schedule to schedule it programatically (and hard code it) as part of the managed package? If you are doing it programatically, are there any issues for end customer, because you are hard-coding a time for them ?

 

 

 

jhartjhart

Yep, we're an ISV (see the Absolute Automation link in my sig).

 

We call System.Schedule during our post-install setup phase.  Asking customers to do yet another thing during the setup phase is a non-starter (just getting them to clone profiles so they can grant permission to custom objects is difficult enough at scale, let alone the 10 other things that need to get done).  Anything we can do in code, we do.

 

The job runs hourly, but we pick a random minute as the start time when we schedule it so all our customers' jobs don't launch at the same time & stampede SFDC's servers.

GoForceGoGoForceGo

Thanks for the info. We don't have an explicity setup-phase yet - will have to add it to enable system.schedule.

 

Is this live for some of your customers in Professional Edition?

 

 

jhartjhart

We don't support PE yet, although it's on the roadmap.  Is this going to be one of the sticky points as we backport to Pro?

 

Are you an ISV as well?  What app?

GoForceGoGoForceGo

 

I don't think this should be stickly point. If Apex is enabled in PE, all Apex functionality should exist.

 

 

 

jkucerajkucera

I stumbled upon this thread as I was trying to build an app to move Chatter posts from the loser to the winner.  It seems that the loser's masterRecordId is set to equal the loser's Id, not the winner's id, which I would have thought would have thwarted any efforts to reparent stuff as mentioned in this thread.  

 

Have any of you experienced this behavior?  It seems like a pretty bad bug, but I'm surprised it hasn't been reported in this thread or filed as a case before. 

 

To reproduce:

        String lastName='L1';
        String company='a';
        Lead l= new Lead(LastName=lastName, Company=company);
        lastName='L2';
        Lead l2= new Lead(LastName=lastName, Company=company);
 
        insert l;
        insert l2;

        merge l2 l;


To see the incorrect output, this trigger for logging:

trigger testLeadMerge on Lead (after update, after delete) {
if(system.trigger.isDelete)
system.debug('trigger.old '+trigger.old);
}

 

Before I have my team dive deeper, I wanted to make sure I'm not missing something.  The doc isn't the most clear either, so I can work with our writers to refresh the help to make the behavior more clear as well.  Thanks!

jhartjhart

Are you sure you are reading the IDs correctly?


I just put analogous debug output into one of my merge-handling triggers, and ran the test for it, and got this output:

 

 

Id=            00Q7000000kGgPWEA0
MasterRecordId=00Q7000000kGgPVEA0

 They look pretty similar, right?  I even thought I had reproduced your case for a second, until I noticed the "W"-vs-"V" difference.

 


We have heavily tested code that depends on MasterRecordId being the winner's ID.  We would get test failures all over the place if MasterRecordId pointed at the wrong object.