diff --git a/nebula-logger/core/main/log-management/classes/LogBatchPurger.cls b/nebula-logger/core/main/log-management/classes/LogBatchPurger.cls index 2a197e264..b36bf88f7 100644 --- a/nebula-logger/core/main/log-management/classes/LogBatchPurger.cls +++ b/nebula-logger/core/main/log-management/classes/LogBatchPurger.cls @@ -119,7 +119,7 @@ global with sharing class LogBatchPurger implements Database.Batchable, LoggerBatchableContext input = new LoggerBatchableContext(batchableContext, this.currentSObjectType); this.executePlugins(BatchableMethod.EXECUTE, input, scopeRecords); - LoggerDataStore.getDatabase().hardDeleteRecords(scopeRecords); + this.hardDeleteRecords(scopeRecords); Logger.saveLog(); } catch (System.Exception apexException) { if (LoggerParameter.ENABLE_SYSTEM_MESSAGES) { @@ -246,6 +246,42 @@ global with sharing class LogBatchPurger implements Database.Batchable, return queryLocator; } + private void hardDeleteRecords(List scopeRecords) { + try { + LoggerDataStore.getDatabase().hardDeleteRecords(scopeRecords); + } catch (System.DmlException dmlException) { + this.hardDeleteRecordsWithPartialDeletes(scopeRecords); + } + } + + private void hardDeleteRecordsWithPartialDeletes(List scopeRecords) { + // A batch delete can fail when the DML cascades to too many related records at once + // (e.g., DELETE_OPERATION_TOO_LARGE on LogEntry__c or FeedItem records). Retry with partial + // deletes so that records that can be deleted are still purged, and log any records that fail + List deleteResults = LoggerDataStore.getDatabase().deleteRecords(scopeRecords, false); + List deletedRecords = new List(); + List failedDeleteResults = new List(); + for (Integer i = 0; i < deleteResults.size(); i++) { + Database.DeleteResult deleteResult = deleteResults.get(i); + if (deleteResult.isSuccess()) { + deletedRecords.add(scopeRecords.get(i)); + } else { + failedDeleteResults.add(deleteResult); + } + } + + if (deletedRecords.isEmpty() == false) { + LoggerDataStore.getDatabase().emptyRecycleBin(deletedRecords); + } + + if (failedDeleteResults.isEmpty() == false && LoggerParameter.ENABLE_SYSTEM_MESSAGES) { + Logger.error( + new LogMessage('Nebula Logger - Unable to delete {0} {1} record(s)', failedDeleteResults.size(), this.currentSObjectType), + failedDeleteResults + ); + } + } + private void executePlugins(BatchableMethod method, LoggerBatchableContext input, List scopeRecords) { for (LoggerPlugin__mdt pluginConfiguration : this.pluginConfigurations) { if (String.isNotBlank(pluginConfiguration.BatchPurgerApexClass__c)) { diff --git a/nebula-logger/core/main/logger-engine/classes/LoggerDataStore.cls b/nebula-logger/core/main/logger-engine/classes/LoggerDataStore.cls index 46d1521ef..d128fd11c 100644 --- a/nebula-logger/core/main/logger-engine/classes/LoggerDataStore.cls +++ b/nebula-logger/core/main/logger-engine/classes/LoggerDataStore.cls @@ -129,6 +129,15 @@ public without sharing class LoggerDataStore { return System.Database.delete(records, allOrNone); } + /** + * @description Executes an `emptyRecycleBin` DML operation on the provided list of `SObject` records + * @param records The list of `SObject` records to hard delete + * @return The instance of `List`, generated by the platform when hard deleting the records + */ + public virtual List emptyRecycleBin(List records) { + return System.Database.emptyRecycleBin(records); + } + /** * @description Executes a `delete` DML operation for the `SObject` record, followed by hard deleting the * record using `Database.emptyRecycleBin(record)` @@ -148,7 +157,7 @@ public without sharing class LoggerDataStore { public virtual List hardDeleteRecords(List records) { List results = this.deleteRecords(records); if (records.isEmpty() == false) { - System.Database.emptyRecycleBin(records); + this.emptyRecycleBin(records); } return results; } diff --git a/nebula-logger/core/tests/log-management/classes/LogBatchPurger_Tests.cls b/nebula-logger/core/tests/log-management/classes/LogBatchPurger_Tests.cls index 6ef403b5b..1714944cd 100644 --- a/nebula-logger/core/tests/log-management/classes/LogBatchPurger_Tests.cls +++ b/nebula-logger/core/tests/log-management/classes/LogBatchPurger_Tests.cls @@ -85,6 +85,32 @@ private class LogBatchPurger_Tests { System.Assert.isNotNull(thrownNullPointerException); } + @IsTest + static void it_should_partially_delete_records_when_batch_delete_throws_dml_exception() { + LoggerTestConfigurator.setMock(new LoggerParameter__mdt(DeveloperName = 'EnableLoggerSystemMessages', Value__c = 'true')); + Log__c deletableLog = new Log__c(Id = LoggerMockDataCreator.createId(Schema.Log__c.SObjectType)); + Log__c undeletableLog = new Log__c(Id = LoggerMockDataCreator.createId(Schema.Log__c.SObjectType)); + List scopeRecords = new List{ deletableLog, undeletableLog }; + MockPartiallyFailingDatabase mockDatabase = new MockPartiallyFailingDatabase(); + mockDatabase.undeletableRecordIds.add(undeletableLog.Id); + LoggerDataStore.setMock(mockDatabase); + LogBatchPurger batchJobInstance = new LogBatchPurger(); + batchJobInstance.currentSObjectType = Schema.Log__c.SObjectType; + Database.BatchableContext mockBatchableContext = new LoggerMockDataCreator.MockBatchableContext(); + + batchJobInstance.execute(mockBatchableContext, scopeRecords); + + System.Assert.areEqual(1, mockDatabase.allOrNoneDeleteCallCount, 'The all-or-none delete should have been attempted once'); + System.Assert.areEqual(1, mockDatabase.partialDeleteCallCount, 'The partial delete should have been attempted once'); + System.Assert.areEqual(1, mockDatabase.emptyRecycleBinCallCount, 'Deleted records should have been removed from the recycle bin'); + System.Assert.areEqual( + new List{ deletableLog }, + mockDatabase.hardDeletedRecords, + 'Only the records that were successfully deleted should have been removed from the recycle bin' + ); + LoggerDataStore.setMock((LoggerDataStore.Database) null); + } + @IsTest static void it_should_default_batch_size_for_chained_job_batch_size() { LogBatchPurger batchJobInstance = new LogBatchPurger(); @@ -435,4 +461,38 @@ private class LogBatchPurger_Tests { batchInput = input; } } + + public class MockPartiallyFailingDatabase extends LoggerDataStore.Database { + public final Set undeletableRecordIds = new Set(); + public Integer allOrNoneDeleteCallCount = 0; + public Integer emptyRecycleBinCallCount = 0; + public List hardDeletedRecords; + public Integer partialDeleteCallCount = 0; + + public override List deleteRecords(List records) { + this.allOrNoneDeleteCallCount++; + System.DmlException dmlException = new System.DmlException(); + dmlException.setMessage('Cascade deleting too many records: DELETE_OPERATION_TOO_LARGE'); + throw dmlException; + } + + public override List deleteRecords(List records, Boolean allOrNone) { + this.partialDeleteCallCount++; + List deleteResults = new List(); + for (SObject record : records) { + deleteResults.add(LoggerMockDataCreator.createDatabaseDeleteResult(this.undeletableRecordIds.contains(record.Id) == false, record.Id)); + } + return deleteResults; + } + + public override List emptyRecycleBin(List records) { + this.emptyRecycleBinCallCount++; + this.hardDeletedRecords = records; + List emptyRecycleBinResults = new List(); + for (SObject record : records) { + emptyRecycleBinResults.add(LoggerMockDataCreator.createDatabaseEmptyRecycleBinResult(true, record.Id)); + } + return emptyRecycleBinResults; + } + } }