I have no idea what I actually did to make things work, but they seem to be better now (just did a 150-record upsert that worked followed by a 170-record upsert that worked)...
I changed the trigger to an After trigger. It's got a bunch more overhead (e.g. re-querying the records), but I was desperate and just trying anything different.
In that After trigger, instead of doing the standard "Static Boolean"-based recursion prevention, I switched to "check the IDs you have already processed-based" recursion prevention as indicated here.
New code:
public with sharing class ContactTriggerHandler {
...
public static void copyDailyDumpDataAfterInsertOrUpdate(Map<Id,Contact> newContacts, Map<Id, Contact> oldContacts, Boolean isInsert) {
// Make sure this method only runs once per set of just-inserted/updated contacts.
if (alreadyProcessed.containsAll(newContacts.keySet())) {return;}
alreadyProcessed.addAll(newContacts.keySet());
List<Id> dumpCIds = new List<Id>();
List<Contact> csToUpdate = new List<Contact>();
for (Contact c : newContacts.values()) {
// Cache as an "DDmp" Contact record if it was just inserted/updated by the DDmp Jitterbit load
if (c.DailyDump_Contact_Id__c <> null &&
c.DDmp_Jitterbit_Most_Recent_Load_DateTime__c <> null &&
(isInsert ||
oldContacts == null ||
oldContacts.get(c.Id).DDmp_Jitterbit_Most_Recent_Load_DateTime__c == null ||
oldContacts.get(c.Id).DDmp_Jitterbit_Most_Recent_Load_DateTime__c < c.DDmp_Jitterbit_Most_Recent_Load_DateTime__c)
) {
dumpCIds.add(c.Id);
}
}
if (dumpCIds.size()>0) {
Boolean chgdDump = false;
for (Contact c : [SELECT Id
,PIDM__c,DDmp_Copy_of_PIDM__c
,Banner_UST_ID__c,DDmp_Copy_Of_Banner_ID__c
,Birthdate,DDmp_Birthdate__c
,Citizenship__c,DDmp_Applicant_Citizenship_Status__c
,Company_Holding_Spot__c,DDmp_Account_Name__c
,Email,DDmp_Email__c
,FirstName,DDmp_FirstName__c
,Gender__c,DDmp_Gender__c
,MailingStreet,DDmp_MailingStreetLine1__c,DDmp_MailingStreetLine2__c,DDmp_MailingStreetLine3__c
,MailingCity,DDmp_MailingCity__c
,MailingState,DDmp_MailingState__c
,MailingPostalCode,DDmp_MailingZip__c
,MailingCountry,DDmp_MailingCountry__c
,Phone,OtherPhone,DDmp_HomePhone__c,DDmp_MobilePhone__c,DDmp_Work_Phone__c
FROM Contact WHERE Id IN :dumpCIds]) {
{
// Smart-edit various fields
if (c.PIDM__c==null && c.DDmp_Copy_of_PIDM__c<>null) c.PIDM__c = c.DDmp_Copy_of_PIDM__c; chgdDump=TRUE;
if (c.Banner_UST_ID__c==null && c.DDmp_Copy_Of_Banner_ID__c<>null) c.Banner_UST_ID__c = c.DDmp_Copy_Of_Banner_ID__c; chgdDump=TRUE;
if (c.Birthdate==null && c.DDmp_Birthdate__c<>null) c.Birthdate = c.DDmp_Birthdate__c; chgdDump=TRUE;
if (c.Citizenship__c==null && c.DDmp_Applicant_Citizenship_Status__c<>null) c.Citizenship__c = c.DDmp_Applicant_Citizenship_Status__c; chgdDump=TRUE;
if (c.Company_Holding_Spot__c==null && c.DDmp_Account_Name__c<>null) c.Company_Holding_Spot__c = c.DDmp_Account_Name__c; chgdDump=TRUE;
if (c.Email==null && c.DDmp_Email__c<>null) c.Email = c.DDmp_Email__c; chgdDump=TRUE;
System.debug('First names: ' + c.FirstName + c.DDmp_FirstName__c); // DEBUG LINE ONLY
if ((c.FirstName==null || Test.IsRunningTest()) && c.DDmp_FirstName__c<>null) c.FirstName = c.DDmp_FirstName__c; chgdDump=TRUE; // To make debug logs easier to read, our test to make sure this works starts with a FirstName in place, unlike real data, hence the alternative check for isRunningTest for overwriting.
if (c.Gender__c==null && c.DDmp_Gender__c<>null) c.Gender__c = c.DDmp_Gender__c; chgdDump=TRUE;
if (c.MailingStreet==null && c.DDmp_MailingStreetLine1__c<>null) c.MailingStreet = c.DDmp_MailingStreetLine1__c +
((c.DDmp_MailingStreetLine2__c <> null) ? ('\r\n' + c.DDmp_MailingStreetLine2__c) : '') +
((c.DDmp_MailingStreetLine3__c <> null) ? ('\r\n' + c.DDmp_MailingStreetLine3__c) : ''); chgdDump=TRUE;
if (c.MailingCity==null && c.DDmp_MailingCity__c<>null) c.MailingCity = c.DDmp_MailingCity__c; chgdDump=TRUE;
if (c.MailingState==null && c.DDmp_MailingState__c<>null) c.MailingState = c.DDmp_MailingState__c; chgdDump=TRUE;
if (c.MailingPostalCode==null && c.DDmp_MailingZip__c<>null) c.MailingPostalCode = c.DDmp_MailingZip__c; chgdDump=TRUE;
if (c.MailingCountry==null && c.DDmp_MailingCountry__c<>null) c.MailingCountry = c.DDmp_MailingCountry__c; chgdDump=TRUE;
// Take phone number from best in preference order Home, Mobile, Work
if (c.Phone==null) {
if (c.DDmp_HomePhone__c<>null) {c.Phone=c.DDmp_HomePhone__c; chgdDump=TRUE;}
else if (c.DDmp_MobilePhone__c<>null) {c.Phone=c.DDmp_MobilePhone__c; chgdDump=TRUE;}
else c.Phone=c.DDmp_Work_Phone__c; chgdDump=TRUE;
}
// If another phone exists, take it from 2nd-best in preference order Home, Mobile, Work
if (c.OtherPhone == null) {
if (c.DDmp_HomePhone__c<>null) {c.OtherPhone = (c.DDmp_MobilePhone__c<>null) ? c.DDmp_MobilePhone__c : c.DDmp_Work_Phone__c ; chgdDump=TRUE;}
}
}
// If anything was smart-edited, set the record aside for updating
if (chgdDump) csToUpdate.add(c);
chgdDump=FALSE;
}
}
if(csToUpdate.size()>0) {
UPDATE csToUpdate;
}
}
...
}
Still not sure why a simple "before" trigger refused to play nice but the "after" trigger works just fine.