From 8b02644a6c4857665e9ace9ee0977689040ef651 Mon Sep 17 00:00:00 2001 From: Raj <65381920+RajPabnani03@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:10:50 -0700 Subject: [PATCH] Fix #1026: handle DELETE_OPERATION_TOO_LARGE in LogBatchPurger 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), which caused the whole LogBatchPurger job to fail with an unhandled exception and prevented deletable records from being purged. On a DmlException from the all-or-none delete, LogBatchPurger now retries with a partial delete: successfully deleted records are still purged (and removed from the recycle bin), and any records that still fail are logged as errors when system messages are enabled. The job then continues instead of failing the batch. Also adds a virtual emptyRecycleBin() method to LoggerDataStore.Database so the full delete chain remains mockable in tests. --- .../log-management/classes/LogBatchPurger.cls | 38 +++++++++++- .../logger-engine/classes/LoggerDataStore.cls | 11 +++- .../classes/LogBatchPurger_Tests.cls | 60 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) 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; + } + } }