diff --git a/README.md b/README.md index 9886144..9010f08 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ Welcome to the Custom Async Framework for Salesforce Apex! This project provides - [Platform Event Setup](#platform-event-setup) - [EnqueueJobs Class](#enqueuejobs-class) - [AsyncJobTriggerHandler Class](#asyncjobtriggerhandler-class) + - [AsyncJobUtils Class](#asyncjobutils-class) - [Limitations](#limitations) + - [Error Handling](#error-handling) + - [Monitoring and Troubleshooting](#monitoring-and-troubleshooting) + - [Performance Best Practices](#performance-best-practices) - [Contributing](#contributing) - [License](#license) @@ -31,10 +35,14 @@ Salesforce provides powerful tools for asynchronous processing like Queueable an ## Features -- Alternative to Queueable and Batch Apex. -- Utilizes Salesforce Platform Cache for storing class instances, reducing the overhead of object initialization. -- Leverages Platform Events as an event bus for executing actions in a sequence. -- Easy-to-use and intuitive API for developers. +- **Alternative to Queueable and Batch Apex** - Provides custom async processing when standard solutions don't fit. +- **Platform Cache Integration** - Stores class instances in cache, reducing overhead of object initialization. +- **Platform Events Bus** - Leverages events for executing actions in sequence with automatic chaining. +- **Cursor-Based Processing** - Uses Database.Cursor for efficient memory usage with large datasets. +- **Comprehensive Error Handling** - Built-in exception handling, logging, and error recovery. +- **Input Validation** - Validates parameters to prevent common configuration errors. +- **Stateful & Stateless Modes** - Support for both stateful and stateless batch processing. +- **Developer-Friendly API** - Easy-to-use and intuitive interface for developers. ## Installation @@ -164,28 +172,111 @@ The Platform Event setup is essential for handling asynchronous events in the Cu ### EnqueueJobs Class -The `EnqueueJobs` class is responsible for enqueuing asynchronous jobs, either Queueable or Batch. - +The `EnqueueJobs` class is responsible for enqueuing asynchronous jobs, either Queueable or Batch. It provides validation and error handling for all job submissions. ### AsyncJobTriggerHandler Class -The `AsyncJobTriggerHandler` class is responsible for processing Queue and Batch jobs triggered by Platform Events. +The `AsyncJobTriggerHandler` class is responsible for processing Queue and Batch jobs triggered by Platform Events. It includes comprehensive error handling and automatic cache cleanup. + +### AsyncJobUtils Class + +The `AsyncJobUtils` class provides utility methods for monitoring and managing async jobs: + +```apex +// Check if a job exists in cache +Boolean exists = AsyncJobUtils.jobExists(jobId); + +// Get detailed information about a job +Map jobInfo = AsyncJobUtils.getJobInfo(jobId); +System.debug('Job Type: ' + jobInfo.get('type')); +System.debug('Records Processed: ' + jobInfo.get('currentPosition')); + +// Cancel a pending job (before it's picked up by event handler) +Boolean cancelled = AsyncJobUtils.cancelJob(jobId); + +// Get list of all active jobs +List activeJobs = AsyncJobUtils.getActiveJobIds(); +System.debug('Active jobs count: ' + activeJobs.size()); +// Get count of active jobs +Integer count = AsyncJobUtils.getActiveJobCount(); +``` ## Limitations -While the Custom Async Framework offers an alternative for asynchronous processing in Salesforce Apex, it also has many limitations that developers should be aware of: +While the Custom Async Framework offers an alternative for asynchronous processing in Salesforce Apex, it also has limitations that developers should be aware of: 1. **No Direct Callouts**: Framework does not support making callouts for obvious reasons. -2. **No Querylocator**: Framework does not support using the `Database.getQueryLocator()` method within Batch type jobs. If your use case requires this feature, please be aware that it is not compatible with the framework's current implementation. +2. **No Querylocator**: Framework does not support using the `Database.getQueryLocator()` method within Batch type jobs. Instead, it uses `Database.getCursor()` for efficient record processing. -3. **Limited Number of Records**: In Batch jobs, the number of records returned by the `start` method should not exceed 100 KB. +3. **Limited Number of Records**: In Batch jobs, the number of records returned by the `start` method should not exceed 100 KB per cursor operation. 4. **Platform Cache Limits**: The usage of Platform Cache is subject to its own limits and allocations in your Salesforce org. Ensure that you monitor the cache usage to avoid reaching the limits. +5. **Governor Limits**: As with any Salesforce Apex code, the Custom Async Framework is subject to governor limits. Ensure that your asynchronous jobs are designed to comply with these limits. + +## Error Handling + +The framework includes comprehensive error handling: + +- **AsyncException**: Custom exception class with error type categorization (CACHE_ERROR, BATCH_EXECUTION_ERROR, QUEUE_EXECUTION_ERROR, INVALID_CONFIGURATION, CURSOR_ERROR) +- **AsyncLogger**: Structured logging for monitoring and troubleshooting with different log levels (ERROR, WARNING, INFO, DEBUG) +- **Input Validation**: All public methods validate inputs and throw descriptive exceptions for invalid parameters +- **Automatic Cleanup**: Cache entries are automatically cleaned up on errors to prevent orphaned data + +### Best Practices for Error Handling + +```apex +// Wrap your batch/queue execute logic in try-catch for custom error handling +public class MyRobustBatch implements Async.Batch { + public String start() { + return 'SELECT Id, Name FROM Account WHERE Status__c = \'Active\''; + } + + public void execute(List scope) { + try { + // Your processing logic + List accounts = (List)scope; + for (Account acc : accounts) { + // Process account + } + update scope; + } catch (DmlException ex) { + // Handle DML errors gracefully + System.debug(LoggingLevel.ERROR, 'DML Error: ' + ex.getMessage()); + // Optionally log to custom object or send notification + } + } + + public void finish() { + // Send completion notification or cleanup + } +} +``` + +## Monitoring and Troubleshooting + +The framework provides detailed logging for all operations: + +- Job enqueue operations are logged with job IDs +- Batch chunk processing logs the number of records processed +- Errors are logged with full stack traces and context +- Cache operations are logged for debugging + +To monitor your async jobs, check the debug logs filtered by 'AsyncFramework' prefix. + +## Performance Best Practices + +1. **Batch Size Selection**: Choose appropriate batch sizes (50-200) based on your processing complexity. Smaller batches for complex operations, larger for simple updates. + +2. **Query Optimization**: Ensure your SOQL queries use indexes and are optimized. The `start()` method should return efficient queries. + +3. **Stateful vs Stateless**: Use stateful batches (`implements Async.Stateful`) only when you need to maintain state across chunks. Stateless batches use less memory. + +4. **Cache Monitoring**: Monitor your Platform Cache usage to ensure you don't exceed org limits. -4. **Governor Limits**: As with any Salesforce Apex code, the Custom Async Framework is subject to governor limits. Ensure that your asynchronous jobs are designed to comply with these limits. +5. **Governor Limit Awareness**: Each batch chunk operates within governor limits. Design your execute() logic accordingly. ## Contributing diff --git a/force-app/main/default/classes/Async.cls b/force-app/main/default/classes/Async.cls index ca73bfe..308af82 100644 --- a/force-app/main/default/classes/Async.cls +++ b/force-app/main/default/classes/Async.cls @@ -1,13 +1,49 @@ +/** + * Core interfaces for the Async Framework + * Defines contracts for Queue and Batch jobs with optional state preservation + */ public with sharing class Async { + /** + * Interface for batch processing jobs + * Implement this interface to create custom batch jobs + */ public Interface Batch { - String start(); // MODIFIED: Was Iterable + /** + * Start method - returns SOQL query for records to process + * @return SOQL query string + */ + String start(); + + /** + * Execute method - processes a batch of records + * @param scope List of SObjects to process in this batch + */ void execute(List scope); + + /** + * Finish method - called after all batches are processed + * Use for cleanup, sending notifications, etc. + */ void finish(); } + + /** + * Interface for queue (single execution) jobs + * Implement this interface to create custom queue jobs + */ public Interface Queue { + /** + * Execute method - performs the async operation + */ void execute(); } + + /** + * Marker interface for stateful batch jobs + * Implement this along with Batch to preserve state across execute() calls + * Without this, a new instance is created for each batch chunk + */ public Interface Stateful { - //Marker Interface + // Marker Interface - no methods required } } \ No newline at end of file diff --git a/force-app/main/default/classes/AsyncException.cls b/force-app/main/default/classes/AsyncException.cls new file mode 100644 index 0000000..ac1a127 --- /dev/null +++ b/force-app/main/default/classes/AsyncException.cls @@ -0,0 +1,35 @@ +/** + * Custom exception class for the Async Framework + * Provides better error categorization and handling + */ +public class AsyncException extends Exception { + public enum ErrorType { + CACHE_ERROR, + BATCH_EXECUTION_ERROR, + QUEUE_EXECUTION_ERROR, + INVALID_CONFIGURATION, + CURSOR_ERROR + } + + private ErrorType errorType; + private String jobId; + + public AsyncException(ErrorType errorType, String message) { + this(message); + this.errorType = errorType; + } + + public AsyncException(ErrorType errorType, String message, String jobId) { + this(message); + this.errorType = errorType; + this.jobId = jobId; + } + + public ErrorType getErrorType() { + return this.errorType; + } + + public String getJobId() { + return this.jobId; + } +} diff --git a/force-app/main/default/classes/AsyncException.cls-meta.xml b/force-app/main/default/classes/AsyncException.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncException.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/AsyncExceptionTest.cls b/force-app/main/default/classes/AsyncExceptionTest.cls new file mode 100644 index 0000000..239af1d --- /dev/null +++ b/force-app/main/default/classes/AsyncExceptionTest.cls @@ -0,0 +1,51 @@ +@isTest +private class AsyncExceptionTest { + @isTest + static void testExceptionWithErrorType() { + Test.startTest(); + AsyncException ex = new AsyncException( + AsyncException.ErrorType.CACHE_ERROR, + 'Test cache error' + ); + Test.stopTest(); + + System.assertEquals(AsyncException.ErrorType.CACHE_ERROR, ex.getErrorType()); + System.assertEquals('Test cache error', ex.getMessage()); + } + + @isTest + static void testExceptionWithJobId() { + String testJobId = 'test123'; + + Test.startTest(); + AsyncException ex = new AsyncException( + AsyncException.ErrorType.BATCH_EXECUTION_ERROR, + 'Test batch error', + testJobId + ); + Test.stopTest(); + + System.assertEquals(AsyncException.ErrorType.BATCH_EXECUTION_ERROR, ex.getErrorType()); + System.assertEquals('Test batch error', ex.getMessage()); + System.assertEquals(testJobId, ex.getJobId()); + } + + @isTest + static void testAllErrorTypes() { + // Test that all error types can be used + List errorTypes = new List{ + AsyncException.ErrorType.CACHE_ERROR, + AsyncException.ErrorType.BATCH_EXECUTION_ERROR, + AsyncException.ErrorType.QUEUE_EXECUTION_ERROR, + AsyncException.ErrorType.INVALID_CONFIGURATION, + AsyncException.ErrorType.CURSOR_ERROR + }; + + Test.startTest(); + for (AsyncException.ErrorType errorType : errorTypes) { + AsyncException ex = new AsyncException(errorType, 'Test message'); + System.assertEquals(errorType, ex.getErrorType()); + } + Test.stopTest(); + } +} diff --git a/force-app/main/default/classes/AsyncExceptionTest.cls-meta.xml b/force-app/main/default/classes/AsyncExceptionTest.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncExceptionTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/AsyncJobTriggerHandler.cls b/force-app/main/default/classes/AsyncJobTriggerHandler.cls index e3e5c8d..8fcdfbf 100644 --- a/force-app/main/default/classes/AsyncJobTriggerHandler.cls +++ b/force-app/main/default/classes/AsyncJobTriggerHandler.cls @@ -1,86 +1,164 @@ /** - * Handles Queue and Batch events - **/ + * Handles Queue and Batch events triggered by Platform Events + * Processes async jobs with comprehensive error handling and logging + */ public class AsyncJobTriggerHandler { - private static final String CACHE_PARTITION_NAME = 'local.QueueCache'; // Define constant + private static final String CACHE_PARTITION_NAME = 'local.QueueCache'; - // ... handleQueueEvents (ensure it also uses CACHE_PARTITION_NAME if modified in future) ... + /** + * Handle Queue job events + * @param newEvents List of AsyncJobEvent__e platform events + */ public static void handleQueueEvents(List newEvents) { - Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); // Use constant + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); for (AsyncJobEvent__e event : newEvents) { - if (!(event.Context__c == 'Queueable' && orgPart.contains(event.QueueId__c))) { + String jobId = event.QueueId__c; + + if (event.Context__c != 'Queueable') { continue; } - Async.Queue queue = (Async.Queue)orgPart.get(event.QueueId__c); - System.debug(JSON.serialize(queue)); + + if (!orgPart.contains(jobId)) { + AsyncLogger.logWarning(jobId, 'Queue', 'Job not found in cache, may have already been processed or expired'); + continue; + } + try { + Async.Queue queue = (Async.Queue)orgPart.get(jobId); + AsyncLogger.logInfo(jobId, 'Queue', 'Starting queue job execution'); + queue.execute(); - orgPart.remove(event.QueueId__c); + + orgPart.remove(jobId); + AsyncLogger.logInfo(jobId, 'Queue', 'Queue job completed successfully'); } catch (Exception ex) { - System.debug(ex.getMessage()); + AsyncLogger.logError(jobId, 'Queue', 'Queue job execution failed', ex); + // Clean up cache on error + try { + orgPart.remove(jobId); + } catch (Exception cleanupEx) { + AsyncLogger.logError(jobId, 'Queue', 'Failed to clean up cache after error', cleanupEx); + } } } } + /** + * Handle Batch job events + * @param newEvents List of AsyncJobEvent__e platform events + */ public static void handleBatchEvents(List newEvents) { - Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); // Use constant + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); + for (AsyncJobEvent__e event : newEvents) { - // ... (check if queueId is in orgPart before getting) - if (!(event.Context__c == 'Batch' && orgPart.contains(event.QueueId__c) && orgPart.contains(event.QueueId__c + 'cursorWrapper'))) { - System.debug('AsyncJobTriggerHandler: Batch event ' + event.QueueId__c + ' not found in cache or cursorWrapper missing.'); + String jobId = event.QueueId__c; + + if (event.Context__c != 'Batch') { continue; } - Async.Batch batch = (Async.Batch)orgPart.get(event.QueueId__c); - // MODIFIED: Retrieve CursorWrapper instead of Iterator - CursorWrapper cursorWrapper = (CursorWrapper)orgPart.get(event.QueueId__c + 'cursorWrapper'); - - Integer batchSize = (Integer)event.BatchSize__c; - + + if (!orgPart.contains(jobId)) { + AsyncLogger.logWarning(jobId, 'Batch', 'Batch job not found in cache'); + continue; + } + + if (!orgPart.contains(jobId + 'cursorWrapper')) { + AsyncLogger.logError(jobId, 'Batch', 'CursorWrapper not found in cache, cannot process batch', null); + // Clean up orphaned batch object + try { + orgPart.remove(jobId); + } catch (Exception cleanupEx) { + AsyncLogger.logError(jobId, 'Batch', 'Failed to clean up orphaned batch', cleanupEx); + } + continue; + } + try { - // MODIFIED: Fetch chunk using CursorWrapper + Async.Batch batch = (Async.Batch)orgPart.get(jobId); + CursorWrapper cursorWrapper = (CursorWrapper)orgPart.get(jobId + 'cursorWrapper'); + Integer batchSize = (Integer)event.BatchSize__c; + + AsyncLogger.logInfo(jobId, 'Batch', 'Processing batch chunk with size: ' + batchSize); + List scope = cursorWrapper.fetchNextChunk(batchSize); - - processScope(batch, scope); // processScope remains largely the same internally - - // MODIFIED: Check continuation and publish next event + + if (!scope.isEmpty()) { + processScope(batch, scope, jobId); + AsyncLogger.logInfo(jobId, 'Batch', 'Processed ' + scope.size() + ' records'); + } + if (cursorWrapper.hasMoreRecords()) { - publishBatchEvent(batch, cursorWrapper, batchSize); // Pass CursorWrapper + AsyncLogger.logInfo(jobId, 'Batch', 'More records to process, publishing next batch event'); + publishBatchEvent(batch, cursorWrapper, batchSize); } else { + AsyncLogger.logInfo(jobId, 'Batch', 'All records processed, calling finish'); batch.finish(); + AsyncLogger.logInfo(jobId, 'Batch', 'Batch job completed successfully'); } - - orgPart.remove(event.QueueId__c); - orgPart.remove(event.QueueId__c + 'cursorWrapper'); // MODIFIED: Remove CursorWrapper from cache + + // Clean up current cache entries + orgPart.remove(jobId); + orgPart.remove(jobId + 'cursorWrapper'); } catch (Exception ex) { - System.debug(ex.getMessage()); + AsyncLogger.logError(jobId, 'Batch', 'Batch job execution failed', ex); + // Clean up cache on error + try { + orgPart.remove(jobId); + orgPart.remove(jobId + 'cursorWrapper'); + } catch (Exception cleanupEx) { + AsyncLogger.logError(jobId, 'Batch', 'Failed to clean up cache after error', cleanupEx); + } } } } - private static void processScope(Async.Batch batch, List scope) { - if(scope.isEmpty()) + /** + * Process a scope of records for a batch job + * Handles both stateful and non-stateful batch instances + */ + private static void processScope(Async.Batch batch, List scope, String jobId) { + if (scope.isEmpty()) { + AsyncLogger.logDebug(jobId, 'Batch', 'Empty scope, skipping processing'); return; + } + + // For non-stateful batches, create a new instance if (!(batch instanceof Async.Stateful)) { - batch = (Async.Batch)Type.forName(String.valueOf(batch).split(':')[0]).newInstance(); + try { + batch = (Async.Batch)Type.forName(String.valueOf(batch).split(':')[0]).newInstance(); + } catch (Exception ex) { + AsyncLogger.logError(jobId, 'Batch', 'Failed to instantiate non-stateful batch', ex); + throw new AsyncException(AsyncException.ErrorType.BATCH_EXECUTION_ERROR, 'Failed to create batch instance: ' + ex.getMessage(), jobId); + } } + batch.execute(scope); } - // MODIFIED: Parameter type changed to CursorWrapper + /** + * Publish the next batch event for continued processing + * Creates new cache entries with a new job ID + */ private static void publishBatchEvent(Async.Batch batch, CursorWrapper cursorWrapper, Integer batchSize) { - Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); // Get partition + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); String batchID = 'asyncBatch' + EnqueueJobs.generateUniqueID(); + + try { + orgPart.put(batchID, batch); + orgPart.put(batchID + 'cursorWrapper', cursorWrapper); + AsyncLogger.logDebug(batchID, 'Batch', 'Cached continuation batch and cursor'); + } catch (Exception ex) { + AsyncLogger.logError(batchID, 'Batch', 'Failed to cache continuation batch', ex); + throw new AsyncException(AsyncException.ErrorType.CACHE_ERROR, 'Failed to cache batch continuation: ' + ex.getMessage(), batchID); + } + AsyncJobEvent__e batchEvent = new AsyncJobEvent__e(); batchEvent.Context__c = 'Batch'; batchEvent.BatchSize__c = batchSize; batchEvent.QueueId__c = batchID; - // Cache.Org.put(batchID, batch); // OLD - // Cache.Org.put(batchID + 'cursorWrapper', cursorWrapper); // OLD - orgPart.put(batchID, batch); // MODIFIED: Use specific partition - orgPart.put(batchID + 'cursorWrapper', cursorWrapper); // MODIFIED: Use specific partition - EventBus.publish(batchEvent); + AsyncLogger.logDebug(batchID, 'Batch', 'Published continuation event'); } } \ No newline at end of file diff --git a/force-app/main/default/classes/AsyncJobUtils.cls b/force-app/main/default/classes/AsyncJobUtils.cls new file mode 100644 index 0000000..1349507 --- /dev/null +++ b/force-app/main/default/classes/AsyncJobUtils.cls @@ -0,0 +1,131 @@ +/** + * Utility class for monitoring and managing async jobs + * Provides methods to check job status and cache contents + */ +public class AsyncJobUtils { + private static final String CACHE_PARTITION_NAME = 'local.QueueCache'; + + /** + * Check if a job exists in cache + * @param jobId The job ID to check + * @return true if the job exists in cache, false otherwise + */ + public static Boolean jobExists(String jobId) { + if (String.isBlank(jobId)) { + return false; + } + + try { + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); + return orgPart.contains(jobId); + } catch (Exception ex) { + AsyncLogger.logError(jobId, 'JobUtils', 'Error checking job existence', ex); + return false; + } + } + + /** + * Get information about a job if it exists in cache + * @param jobId The job ID to query + * @return Map with job information or null if not found + */ + public static Map getJobInfo(String jobId) { + if (String.isBlank(jobId)) { + return null; + } + + try { + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); + Map jobInfo = new Map(); + + jobInfo.put('jobId', jobId); + jobInfo.put('exists', orgPart.contains(jobId)); + + if (jobId.startsWith('asyncBatch')) { + jobInfo.put('type', 'Batch'); + jobInfo.put('hasCursorWrapper', orgPart.contains(jobId + 'cursorWrapper')); + + if (orgPart.contains(jobId + 'cursorWrapper')) { + CursorWrapper wrapper = (CursorWrapper)orgPart.get(jobId + 'cursorWrapper'); + jobInfo.put('totalRecords', wrapper.getTotalRecords()); + jobInfo.put('currentPosition', wrapper.getCurrentPosition()); + jobInfo.put('hasMoreRecords', wrapper.hasMoreRecords()); + } + } else if (jobId.startsWith('async')) { + jobInfo.put('type', 'Queue'); + } + + return jobInfo; + } catch (Exception ex) { + AsyncLogger.logError(jobId, 'JobUtils', 'Error getting job info', ex); + return null; + } + } + + /** + * Cancel a pending job by removing it from cache + * Note: This only works if the job hasn't been picked up by the event handler yet + * @param jobId The job ID to cancel + * @return true if successfully cancelled, false otherwise + */ + public static Boolean cancelJob(String jobId) { + if (String.isBlank(jobId)) { + return false; + } + + try { + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); + + if (!orgPart.contains(jobId)) { + AsyncLogger.logWarning(jobId, 'JobUtils', 'Job not found in cache, may have already completed'); + return false; + } + + // Remove job from cache + orgPart.remove(jobId); + + // If it's a batch job, also remove cursor wrapper + if (jobId.startsWith('asyncBatch') && orgPart.contains(jobId + 'cursorWrapper')) { + orgPart.remove(jobId + 'cursorWrapper'); + } + + AsyncLogger.logInfo(jobId, 'JobUtils', 'Job cancelled successfully'); + return true; + } catch (Exception ex) { + AsyncLogger.logError(jobId, 'JobUtils', 'Error cancelling job', ex); + return false; + } + } + + /** + * Get a list of all active job IDs in cache + * @return List of job IDs currently in cache + */ + public static List getActiveJobIds() { + try { + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); + List allKeys = orgPart.getKeys(); + List jobIds = new List(); + + for (String key : allKeys) { + // Filter out cursorWrapper keys + if (!key.endsWith('cursorWrapper')) { + jobIds.add(key); + } + } + + return jobIds; + } catch (Exception ex) { + AsyncLogger.logError(null, 'JobUtils', 'Error getting active job IDs', ex); + return new List(); + } + } + + /** + * Get count of active jobs in cache + * @return Number of active jobs + */ + public static Integer getActiveJobCount() { + return getActiveJobIds().size(); + } +} diff --git a/force-app/main/default/classes/AsyncJobUtils.cls-meta.xml b/force-app/main/default/classes/AsyncJobUtils.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncJobUtils.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/AsyncJobUtilsTest.cls b/force-app/main/default/classes/AsyncJobUtilsTest.cls new file mode 100644 index 0000000..e463e3d --- /dev/null +++ b/force-app/main/default/classes/AsyncJobUtilsTest.cls @@ -0,0 +1,158 @@ +@isTest +private class AsyncJobUtilsTest { + @TestSetup + static void setupData() { + List accounts = new List(); + for (Integer i = 0; i < 5; i++) { + accounts.add(new Account(Name = 'TestAccount' + i)); + } + insert accounts; + } + + @isTest + static void testJobExists() { + TestQueue queue = new TestQueue(); + + Test.startTest(); + String jobId = EnqueueJobs.Enqueue(queue); + + // Job should exist right after enqueue + Boolean exists = AsyncJobUtils.jobExists(jobId); + Test.stopTest(); + + System.assertEquals(true, exists, 'Job should exist in cache after enqueue'); + } + + @isTest + static void testJobExistsBlankId() { + Test.startTest(); + Boolean exists = AsyncJobUtils.jobExists(''); + Test.stopTest(); + + System.assertEquals(false, exists, 'Blank job ID should return false'); + } + + @isTest + static void testGetJobInfoQueue() { + TestQueue queue = new TestQueue(); + + Test.startTest(); + String jobId = EnqueueJobs.Enqueue(queue); + Map jobInfo = AsyncJobUtils.getJobInfo(jobId); + Test.stopTest(); + + System.assertNotEquals(null, jobInfo, 'Job info should not be null'); + System.assertEquals(jobId, jobInfo.get('jobId')); + System.assertEquals('Queue', jobInfo.get('type')); + System.assertEquals(true, jobInfo.get('exists')); + } + + @isTest + static void testGetJobInfoBatch() { + TestBatch batch = new TestBatch(); + + Test.startTest(); + String jobId = EnqueueJobs.Batch(batch, 100); + Map jobInfo = AsyncJobUtils.getJobInfo(jobId); + Test.stopTest(); + + System.assertNotEquals(null, jobInfo, 'Job info should not be null'); + System.assertEquals(jobId, jobInfo.get('jobId')); + System.assertEquals('Batch', jobInfo.get('type')); + System.assertEquals(true, jobInfo.get('exists')); + System.assertEquals(true, jobInfo.get('hasCursorWrapper')); + } + + @isTest + static void testCancelJob() { + TestQueue queue = new TestQueue(); + + Test.startTest(); + String jobId = EnqueueJobs.Enqueue(queue); + + // Verify job exists + System.assertEquals(true, AsyncJobUtils.jobExists(jobId), 'Job should exist before cancel'); + + // Cancel job + Boolean cancelled = AsyncJobUtils.cancelJob(jobId); + + // Verify job was cancelled + System.assertEquals(true, cancelled, 'Cancel should return true'); + System.assertEquals(false, AsyncJobUtils.jobExists(jobId), 'Job should not exist after cancel'); + Test.stopTest(); + } + + @isTest + static void testCancelBatchJob() { + TestBatch batch = new TestBatch(); + + Test.startTest(); + String jobId = EnqueueJobs.Batch(batch, 100); + + // Verify job and cursor wrapper exist + System.assertEquals(true, AsyncJobUtils.jobExists(jobId), 'Job should exist before cancel'); + + // Cancel job + Boolean cancelled = AsyncJobUtils.cancelJob(jobId); + + // Verify both job and cursor wrapper were removed + System.assertEquals(true, cancelled, 'Cancel should return true'); + System.assertEquals(false, AsyncJobUtils.jobExists(jobId), 'Job should not exist after cancel'); + + Cache.OrgPartition orgPart = Cache.Org.getPartition('local.QueueCache'); + System.assertEquals(false, orgPart.contains(jobId + 'cursorWrapper'), 'Cursor wrapper should be removed'); + Test.stopTest(); + } + + @isTest + static void testGetActiveJobIds() { + TestQueue queue1 = new TestQueue(); + TestQueue queue2 = new TestQueue(); + + Test.startTest(); + String jobId1 = EnqueueJobs.Enqueue(queue1); + String jobId2 = EnqueueJobs.Enqueue(queue2); + + List activeJobs = AsyncJobUtils.getActiveJobIds(); + Test.stopTest(); + + System.assert(activeJobs.size() >= 2, 'Should have at least 2 active jobs'); + System.assert(activeJobs.contains(jobId1), 'Should contain first job'); + System.assert(activeJobs.contains(jobId2), 'Should contain second job'); + } + + @isTest + static void testGetActiveJobCount() { + Test.startTest(); + Integer initialCount = AsyncJobUtils.getActiveJobCount(); + + TestQueue queue = new TestQueue(); + EnqueueJobs.Enqueue(queue); + + Integer newCount = AsyncJobUtils.getActiveJobCount(); + Test.stopTest(); + + System.assertEquals(initialCount + 1, newCount, 'Job count should increase by 1'); + } + + // Helper classes + private class TestQueue implements Async.Queue { + public void execute() { + System.debug('TestQueue executed'); + } + } + + private class TestBatch implements Async.Batch { + public String start() { + return 'SELECT Id, Name FROM Account WHERE Name LIKE \'TestAccount%\''; + } + + public void execute(List scope) { + System.debug('TestBatch executed with ' + scope.size() + ' records'); + } + + public void finish() { + System.debug('TestBatch finished'); + } + } +} diff --git a/force-app/main/default/classes/AsyncJobUtilsTest.cls-meta.xml b/force-app/main/default/classes/AsyncJobUtilsTest.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncJobUtilsTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/AsyncLogger.cls b/force-app/main/default/classes/AsyncLogger.cls new file mode 100644 index 0000000..b571e7f --- /dev/null +++ b/force-app/main/default/classes/AsyncLogger.cls @@ -0,0 +1,53 @@ +/** + * Logging utility for the Async Framework + * Provides structured logging for monitoring and troubleshooting + */ +public class AsyncLogger { + public enum LogLevel { + ERROR, + WARNING, + INFO, + DEBUG + } + + /** + * Log an error with job context + */ + public static void logError(String jobId, String context, String message, Exception ex) { + String logMessage = buildLogMessage(LogLevel.ERROR, jobId, context, message); + if (ex != null) { + logMessage += '\nException: ' + ex.getTypeName() + '\nMessage: ' + ex.getMessage() + '\nStack Trace: ' + ex.getStackTraceString(); + } + System.debug(LoggingLevel.ERROR, logMessage); + } + + /** + * Log a warning with job context + */ + public static void logWarning(String jobId, String context, String message) { + String logMessage = buildLogMessage(LogLevel.WARNING, jobId, context, message); + System.debug(LoggingLevel.WARN, logMessage); + } + + /** + * Log info message with job context + */ + public static void logInfo(String jobId, String context, String message) { + String logMessage = buildLogMessage(LogLevel.INFO, jobId, context, message); + System.debug(LoggingLevel.INFO, logMessage); + } + + /** + * Log debug message with job context + */ + public static void logDebug(String jobId, String context, String message) { + String logMessage = buildLogMessage(LogLevel.DEBUG, jobId, context, message); + System.debug(LoggingLevel.DEBUG, logMessage); + } + + private static String buildLogMessage(LogLevel level, String jobId, String context, String message) { + String safeJobId = String.isBlank(jobId) ? 'N/A' : jobId; + String safeContext = String.isBlank(context) ? 'N/A' : context; + return '[AsyncFramework] [' + level.name() + '] [JobId: ' + safeJobId + '] [Context: ' + safeContext + '] ' + message; + } +} diff --git a/force-app/main/default/classes/AsyncLogger.cls-meta.xml b/force-app/main/default/classes/AsyncLogger.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncLogger.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/AsyncLoggerTest.cls b/force-app/main/default/classes/AsyncLoggerTest.cls new file mode 100644 index 0000000..7a8e635 --- /dev/null +++ b/force-app/main/default/classes/AsyncLoggerTest.cls @@ -0,0 +1,59 @@ +@isTest +private class AsyncLoggerTest { + @isTest + static void testLogError() { + String jobId = 'testJob123'; + Exception testEx = new AsyncException(AsyncException.ErrorType.CACHE_ERROR, 'Test error'); + + Test.startTest(); + AsyncLogger.logError(jobId, 'TestContext', 'Error occurred', testEx); + Test.stopTest(); + + // Verify no exceptions thrown + System.assert(true, 'Logger should handle errors gracefully'); + } + + @isTest + static void testLogWarning() { + String jobId = 'testJob456'; + + Test.startTest(); + AsyncLogger.logWarning(jobId, 'TestContext', 'Warning message'); + Test.stopTest(); + + System.assert(true, 'Logger should handle warnings gracefully'); + } + + @isTest + static void testLogInfo() { + String jobId = 'testJob789'; + + Test.startTest(); + AsyncLogger.logInfo(jobId, 'TestContext', 'Info message'); + Test.stopTest(); + + System.assert(true, 'Logger should handle info messages gracefully'); + } + + @isTest + static void testLogDebug() { + String jobId = 'testJob000'; + + Test.startTest(); + AsyncLogger.logDebug(jobId, 'TestContext', 'Debug message'); + Test.stopTest(); + + System.assert(true, 'Logger should handle debug messages gracefully'); + } + + @isTest + static void testLogErrorWithNullException() { + String jobId = 'testJobNull'; + + Test.startTest(); + AsyncLogger.logError(jobId, 'TestContext', 'Error without exception', null); + Test.stopTest(); + + System.assert(true, 'Logger should handle null exception gracefully'); + } +} diff --git a/force-app/main/default/classes/AsyncLoggerTest.cls-meta.xml b/force-app/main/default/classes/AsyncLoggerTest.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/AsyncLoggerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/CursorWrapper.cls b/force-app/main/default/classes/CursorWrapper.cls index 9102c88..4ea0f77 100644 --- a/force-app/main/default/classes/CursorWrapper.cls +++ b/force-app/main/default/classes/CursorWrapper.cls @@ -1,3 +1,7 @@ +/** + * Wrapper for Database.Cursor to enable chunked record processing + * Designed to be stored in Platform Cache with transient cursor field + */ public class CursorWrapper { private String query; private transient Database.Cursor cursor; @@ -5,75 +9,119 @@ public class CursorWrapper { private Integer totalRecords; private Boolean isInitialized; + /** + * Create a new CursorWrapper with a SOQL query + * @param query The SOQL query to execute + */ public CursorWrapper(String query) { this.query = query; this.currentPosition = 0; - this.totalRecords = -1; // Initialize to indicate not yet fetched + this.totalRecords = -1; this.isInitialized = false; } + /** + * Initialize the cursor if not already initialized + * Re-initializes transient cursor after deserialization from cache + */ private void initializeCursor() { if (!this.isInitialized) { if (String.isBlank(this.query)) { - // Or throw an exception this.totalRecords = 0; this.isInitialized = true; return; } - this.cursor = Database.getCursor(this.query); - // Ensure getNumRecords is called only after getCursor - this.totalRecords = this.cursor.getNumRecords(); - this.isInitialized = true; + + try { + this.cursor = Database.getCursor(this.query); + this.totalRecords = this.cursor.getNumRecords(); + this.isInitialized = true; + } catch (Exception ex) { + throw new AsyncException(AsyncException.ErrorType.CURSOR_ERROR, 'Failed to initialize cursor: ' + ex.getMessage()); + } } else if (this.cursor == null && String.isNotBlank(this.query)) { // Re-initialize transient cursor if object was deserialized from cache - this.cursor = Database.getCursor(this.query); + try { + this.cursor = Database.getCursor(this.query); + } catch (Exception ex) { + throw new AsyncException(AsyncException.ErrorType.CURSOR_ERROR, 'Failed to re-initialize cursor: ' + ex.getMessage()); + } } } + /** + * Fetch the next chunk of records + * @param chunkSize Number of records to fetch + * @return List of SObjects for this chunk + */ public List fetchNextChunk(Integer chunkSize) { + if (chunkSize == null || chunkSize <= 0) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Chunk size must be greater than 0'); + } + initializeCursor(); if (this.cursor == null || this.currentPosition >= this.totalRecords) { - return new List(); // Return empty list if no cursor or no more records + return new List(); } - List records = this.cursor.fetch(this.currentPosition, chunkSize); - if (records != null) { // fetch can return null - this.currentPosition += records.size(); - } else { - // Handle null case, perhaps by returning empty list or logging - return new List(); + try { + List records = this.cursor.fetch(this.currentPosition, chunkSize); + if (records != null) { + this.currentPosition += records.size(); + } else { + return new List(); + } + return records; + } catch (Exception ex) { + throw new AsyncException(AsyncException.ErrorType.CURSOR_ERROR, 'Failed to fetch records: ' + ex.getMessage()); } - return records; } + /** + * Check if there are more records to process + * @return true if more records are available + */ public Boolean hasMoreRecords() { initializeCursor(); return this.currentPosition < this.totalRecords; } - // Getter methods might be useful for the framework or for debugging + /** + * Get the SOQL query + */ public String getQuery() { return this.query; } + /** + * Get current position in the record set + */ public Integer getCurrentPosition() { return this.currentPosition; } + /** + * Get total number of records + */ public Integer getTotalRecords() { - initializeCursor(); // Ensure totalRecords is populated + initializeCursor(); return this.totalRecords; } + /** + * Check if cursor is initialized + */ public Boolean getIsInitialized() { return this.isInitialized; } - // Setter for query might be needed if the object is created then query set + /** + * Set a new query (resets cursor state) + * @param query The new SOQL query + */ public void setQuery(String query) { this.query = query; - // Reset state if query changes this.cursor = null; this.currentPosition = 0; this.totalRecords = -1; diff --git a/force-app/main/default/classes/CursorWrapperErrorTest.cls b/force-app/main/default/classes/CursorWrapperErrorTest.cls new file mode 100644 index 0000000..13d1916 --- /dev/null +++ b/force-app/main/default/classes/CursorWrapperErrorTest.cls @@ -0,0 +1,69 @@ +@isTest +private class CursorWrapperErrorTest { + /** + * Test invalid chunk size - zero + */ + @isTest + static void testInvalidChunkSizeZero() { + String query = 'SELECT Id FROM Account LIMIT 1'; + CursorWrapper wrapper = new CursorWrapper(query); + Boolean exceptionThrown = false; + + Test.startTest(); + try { + wrapper.fetchNextChunk(0); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + System.assert(ex.getMessage().contains('greater than 0'), 'Should mention chunk size requirement'); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw exception for zero chunk size'); + } + + /** + * Test invalid chunk size - negative + */ + @isTest + static void testInvalidChunkSizeNegative() { + String query = 'SELECT Id FROM Account LIMIT 1'; + CursorWrapper wrapper = new CursorWrapper(query); + Boolean exceptionThrown = false; + + Test.startTest(); + try { + wrapper.fetchNextChunk(-5); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw exception for negative chunk size'); + } + + /** + * Test that invalid SOQL throws appropriate exception + */ + @isTest + static void testInvalidSOQL() { + String invalidQuery = 'SELECT Invalid FROM NonExistent'; + CursorWrapper wrapper = new CursorWrapper(invalidQuery); + + Boolean exceptionThrown = false; + Test.startTest(); + try { + wrapper.fetchNextChunk(10); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.CURSOR_ERROR, ex.getErrorType()); + } catch (Exception ex) { + // May also throw QueryException from Salesforce + exceptionThrown = true; + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw exception for invalid SOQL'); + } +} diff --git a/force-app/main/default/classes/CursorWrapperErrorTest.cls-meta.xml b/force-app/main/default/classes/CursorWrapperErrorTest.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/CursorWrapperErrorTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/EnqueueJobs.cls b/force-app/main/default/classes/EnqueueJobs.cls index 9482bbc..eacd5ca 100644 --- a/force-app/main/default/classes/EnqueueJobs.cls +++ b/force-app/main/default/classes/EnqueueJobs.cls @@ -1,19 +1,53 @@ -/* The EnqueueJobs class is responsible for enqueuing asynchronous jobs, either Queueable or Batch.*/ +/** + * The EnqueueJobs class is responsible for enqueuing asynchronous jobs, either Queueable or Batch. + * Provides methods to submit Queue and Batch jobs to the custom async framework. + */ public with sharing class EnqueueJobs { - private static final String CACHE_PARTITION_NAME = 'local.QueueCache'; // Define constant + private static final String CACHE_PARTITION_NAME = 'local.QueueCache'; + /** + * Enqueue a single Queue job + * @param queue The Queue job to enqueue + * @return The unique job ID for tracking + * @throws AsyncException if queue is null or cache operation fails + */ public static String Enqueue(Async.Queue queue) { + if (queue == null) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Queue cannot be null'); + } return Enqueue(new List{queue})[0]; } + /** + * Enqueue multiple Queue jobs + * @param queues List of Queue jobs to enqueue + * @return List of unique job IDs for tracking + * @throws AsyncException if queues is null/empty or cache operation fails + */ public static List Enqueue(List queues) { - Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); // Get partition + if (queues == null || queues.isEmpty()) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Queues list cannot be null or empty'); + } + + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); List events = new List(); List jobIds = new List(); + for (Async.Queue queue : queues) { + if (queue == null) { + AsyncLogger.logWarning(null, 'Enqueue', 'Skipping null queue in batch enqueue'); + continue; + } + String queueID = 'async' + generateUniqueID(); - // Cache.Org.put(queueID, queue); // OLD - orgPart.put(queueID, queue); // MODIFIED: Use specific partition + + try { + orgPart.put(queueID, queue); + AsyncLogger.logInfo(queueID, 'Enqueue', 'Queue job cached successfully'); + } catch (Exception ex) { + AsyncLogger.logError(queueID, 'Enqueue', 'Failed to cache queue job', ex); + throw new AsyncException(AsyncException.ErrorType.CACHE_ERROR, 'Failed to cache queue job: ' + ex.getMessage(), queueID); + } AsyncJobEvent__e event = new AsyncJobEvent__e(); event.QueueId__c = queueID; @@ -22,24 +56,58 @@ public with sharing class EnqueueJobs { jobIds.add(queueID); } - EventBus.publish(events); + if (!events.isEmpty()) { + EventBus.publish(events); + AsyncLogger.logInfo(null, 'Enqueue', 'Published ' + events.size() + ' queue job events'); + } + return jobIds; } + /** + * Enqueue multiple Batch jobs with specified batch size + * @param Batches List of Batch jobs to enqueue + * @param batchSize Number of records to process in each batch + * @return List of unique job IDs for tracking + * @throws AsyncException if batches is null/empty, batchSize is invalid, or operations fail + */ public static List Batch(List Batches, Integer batchSize) { - Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); // Get partition + if (Batches == null || Batches.isEmpty()) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Batches list cannot be null or empty'); + } + if (batchSize == null || batchSize <= 0 || batchSize > 2000) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Batch size must be between 1 and 2000'); + } + + Cache.OrgPartition orgPart = Cache.Org.getPartition(CACHE_PARTITION_NAME); List events = new List(); List jobIds = new List(); + for (Async.Batch batch : Batches) { - String soqlQuery = batch.start(); + if (batch == null) { + AsyncLogger.logWarning(null, 'Batch', 'Skipping null batch in batch enqueue'); + continue; + } + String batchID = 'asyncBatch' + generateUniqueID(); + + try { + String soqlQuery = batch.start(); + + if (String.isBlank(soqlQuery)) { + AsyncLogger.logWarning(batchID, 'Batch', 'Batch start() returned empty query, skipping'); + continue; + } + + CursorWrapper cursorWrapper = new CursorWrapper(soqlQuery); - CursorWrapper cursorWrapper = new CursorWrapper(soqlQuery); - - // Cache.Org.put(batchID, batch); // OLD - // Cache.Org.put(batchID + 'cursorWrapper', cursorWrapper); // OLD - orgPart.put(batchID, batch); // MODIFIED: Use specific partition - orgPart.put(batchID + 'cursorWrapper', cursorWrapper); // MODIFIED: Use specific partition + orgPart.put(batchID, batch); + orgPart.put(batchID + 'cursorWrapper', cursorWrapper); + AsyncLogger.logInfo(batchID, 'Batch', 'Batch job and cursor cached successfully'); + } catch (Exception ex) { + AsyncLogger.logError(batchID, 'Batch', 'Failed to cache batch job or create cursor', ex); + throw new AsyncException(AsyncException.ErrorType.CACHE_ERROR, 'Failed to cache batch job: ' + ex.getMessage(), batchID); + } AsyncJobEvent__e event = new AsyncJobEvent__e(); event.QueueId__c = batchID; @@ -49,19 +117,45 @@ public with sharing class EnqueueJobs { jobIds.add(batchID); } - EventBus.publish(events); + if (!events.isEmpty()) { + EventBus.publish(events); + AsyncLogger.logInfo(null, 'Batch', 'Published ' + events.size() + ' batch job events'); + } + return jobIds; } + + /** + * Enqueue multiple Batch jobs with default batch size of 200 + */ public static List Batch(List Batches) { return Batch(Batches, 200); } + + /** + * Enqueue a single Batch job with default batch size of 200 + */ public static String Batch(Async.Batch Batch) { + if (Batch == null) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Batch cannot be null'); + } return Batch(new List{Batch}, 200)[0]; } + + /** + * Enqueue a single Batch job with specified batch size + */ public static String Batch(Async.Batch Batch, Integer batchSize) { + if (Batch == null) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Batch cannot be null'); + } return Batch(new List{Batch}, batchSize)[0]; } + /** + * Generate a unique ID for job tracking + * @return A unique hexadecimal string + */ public static String generateUniqueID() { Blob randomBytes = Crypto.GenerateAESKey(128); String hex = EncodingUtil.ConvertToHex(randomBytes); diff --git a/force-app/main/default/classes/EnqueueJobsErrorTest.cls b/force-app/main/default/classes/EnqueueJobsErrorTest.cls new file mode 100644 index 0000000..d5a6ac0 --- /dev/null +++ b/force-app/main/default/classes/EnqueueJobsErrorTest.cls @@ -0,0 +1,180 @@ +@isTest +private class EnqueueJobsErrorTest { + /** + * Test that null queue throws exception + */ + @isTest + static void testEnqueueNullQueue() { + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Enqueue((Async.Queue)null); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + System.assert(ex.getMessage().contains('cannot be null'), 'Should mention null in message'); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for null queue'); + } + + /** + * Test that null batch throws exception + */ + @isTest + static void testBatchNullBatch() { + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Batch((Async.Batch)null); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for null batch'); + } + + /** + * Test that empty queue list throws exception + */ + @isTest + static void testEnqueueEmptyList() { + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Enqueue(new List()); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for empty list'); + } + + /** + * Test that invalid batch size throws exception - zero + */ + @isTest + static void testBatchInvalidSizeZero() { + Async.Batch testBatch = new TestBatch(); + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Batch(testBatch, 0); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + System.assert(ex.getMessage().contains('Batch size'), 'Should mention batch size in message'); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for zero batch size'); + } + + /** + * Test that invalid batch size throws exception - negative + */ + @isTest + static void testBatchInvalidSizeNegative() { + Async.Batch testBatch = new TestBatch(); + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Batch(testBatch, -10); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for negative batch size'); + } + + /** + * Test that invalid batch size throws exception - too large + */ + @isTest + static void testBatchInvalidSizeTooLarge() { + Async.Batch testBatch = new TestBatch(); + Boolean exceptionThrown = false; + + Test.startTest(); + try { + EnqueueJobs.Batch(testBatch, 2001); + } catch (AsyncException ex) { + exceptionThrown = true; + System.assertEquals(AsyncException.ErrorType.INVALID_CONFIGURATION, ex.getErrorType()); + } + Test.stopTest(); + + System.assert(exceptionThrown, 'Should throw AsyncException for batch size over 2000'); + } + + /** + * Test batch list with null items + */ + @isTest + static void testBatchListWithNulls() { + List batches = new List{ + new TestBatch(), + null, + new TestBatch() + }; + + Test.startTest(); + List jobIds = EnqueueJobs.Batch(batches, 100); + Test.stopTest(); + + // Should skip null items and only enqueue valid batches + System.assertEquals(2, jobIds.size(), 'Should only enqueue non-null batches'); + } + + /** + * Test queue list with null items + */ + @isTest + static void testQueueListWithNulls() { + List queues = new List{ + new TestQueue(), + null, + new TestQueue() + }; + + Test.startTest(); + List jobIds = EnqueueJobs.Enqueue(queues); + Test.stopTest(); + + // Should skip null items and only enqueue valid queues + System.assertEquals(2, jobIds.size(), 'Should only enqueue non-null queues'); + } + + // Helper classes + private class TestBatch implements Async.Batch { + public String start() { + return 'SELECT Id FROM Account LIMIT 1'; + } + + public void execute(List scope) { + // Test implementation + } + + public void finish() { + // Test implementation + } + } + + private class TestQueue implements Async.Queue { + public void execute() { + // Test implementation + } + } +} diff --git a/force-app/main/default/classes/EnqueueJobsErrorTest.cls-meta.xml b/force-app/main/default/classes/EnqueueJobsErrorTest.cls-meta.xml new file mode 100644 index 0000000..fbbad0a --- /dev/null +++ b/force-app/main/default/classes/EnqueueJobsErrorTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 56.0 + Active + diff --git a/force-app/main/default/classes/IterableSobject.cls b/force-app/main/default/classes/IterableSobject.cls index e4f319a..3f6886a 100644 --- a/force-app/main/default/classes/IterableSobject.cls +++ b/force-app/main/default/classes/IterableSobject.cls @@ -1,21 +1,45 @@ +/** + * Iterable implementation for SObject queries + * Provides a simple iterator for SOQL query results + * Note: This loads all records into memory at once. For large datasets, + * consider using the Batch interface with CursorWrapper instead. + */ public with sharing class IterableSobject implements Iterable { private String query; + /** + * Create a new IterableSobject with a SOQL query + * @param query The SOQL query to execute + */ public IterableSobject(String query) { + if (String.isBlank(query)) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Query cannot be blank'); + } this.query = query; } + /** + * Get an iterator for the query results + * @return Iterator for SObjects + */ public Iterator iterator() { return new SobjectIterator(query); } + /** + * Inner class that implements the Iterator interface + */ private class SobjectIterator implements Iterator { private List records; private Integer currentIndex; public SobjectIterator(String query) { - this.records = Database.query(query); - this.currentIndex = 0; + try { + this.records = Database.query(query); + this.currentIndex = 0; + } catch (Exception ex) { + throw new AsyncException(AsyncException.ErrorType.CURSOR_ERROR, 'Failed to execute query for iterator: ' + ex.getMessage()); + } } public Boolean hasNext() { diff --git a/force-app/main/default/classes/UpdateContactsBatch.cls b/force-app/main/default/classes/UpdateContactsBatch.cls index a5e6dcf..45aba8e 100644 --- a/force-app/main/default/classes/UpdateContactsBatch.cls +++ b/force-app/main/default/classes/UpdateContactsBatch.cls @@ -1,19 +1,30 @@ +/** + * Example Batch job that updates Contact mailing addresses from Account billing addresses + * Demonstrates stateful batch processing with record counting + */ public without sharing class UpdateContactsBatch implements Async.Batch, Async.Stateful { Integer recordsProcessed = 0; - public String start() { // MODIFIED: Return type and implementation + /** + * Start method - returns SOQL query for accounts with contacts + * @return SOQL query string + */ + public String start() { return 'SELECT ID, BillingStreet, BillingCity, BillingState, ' + 'BillingPostalCode, BillingCountry, (SELECT ID, MailingStreet, MailingCity, ' + 'MailingState, MailingPostalCode, MailingCountry FROM Contacts) FROM Account LIMIT 1500'; } - public void execute(List scope) { // MODIFIED: Parameter type + /** + * Execute method - updates contact mailing addresses for each batch + * @param scope List of Account SObjects with related Contacts + */ + public void execute(List scope) { List contactsToUpdate = new List(); for (SObject sObj : scope) { - Account account = (Account)sObj; // Cast to Account + Account account = (Account)sObj; - // Sub-queried contacts should be accessible via account.Contacts if (account.Contacts != null && !account.Contacts.isEmpty()) { for (Contact contact : account.Contacts) { contact.MailingStreet = account.BillingStreet; @@ -23,7 +34,7 @@ public without sharing class UpdateContactsBatch implements Async.Batch, Async.S contact.MailingCountry = account.BillingCountry; contactsToUpdate.add(contact); - recordsProcessed = recordsProcessed + 1; // Ensure this is incremented appropriately + recordsProcessed++; } } } @@ -33,8 +44,10 @@ public without sharing class UpdateContactsBatch implements Async.Batch, Async.S } } + /** + * Finish method - logs completion message with total records processed + */ public void finish() { - System.debug(recordsProcessed + ' records processed. Shazam!'); - System.debug('Everything finished'); + System.debug(recordsProcessed + ' records processed. Batch job complete!'); } } \ No newline at end of file diff --git a/force-app/main/default/classes/UpdateUserRecord.cls b/force-app/main/default/classes/UpdateUserRecord.cls index 8cabebf..efe8607 100644 --- a/force-app/main/default/classes/UpdateUserRecord.cls +++ b/force-app/main/default/classes/UpdateUserRecord.cls @@ -1,9 +1,24 @@ -public with sharing class UpdateUserRecord implements Async.Queue{ +/** + * Example Queue job that creates a User from a Contact + * Demonstrates simple queue-based async processing + */ +public with sharing class UpdateUserRecord implements Async.Queue { private Contact contact; + + /** + * Constructor + * @param contact The contact to create a user from + */ public UpdateUserRecord(Contact contact) { + if (contact == null) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Contact cannot be null'); + } this.contact = contact; } + /** + * Execute method - creates a new User from the Contact + */ public void execute() { User newUser = new User(); newUser.FirstName = contact.FirstName; @@ -21,11 +36,24 @@ public with sharing class UpdateUserRecord implements Async.Queue{ insert newUser; } + /** + * Generate a user alias from the contact name + * @param contact The contact to generate alias for + * @return Generated alias (max 5 characters: 1 from first name + up to 4 from last name) + */ private static String generateAlias(Contact contact) { - String lastName = contact.lastName.length() > 3 + if (String.isBlank(contact.firstName) || contact.firstName.length() < 1) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Contact firstName must have at least one character for alias generation'); + } + if (String.isBlank(contact.lastName) || contact.lastName.length() < 1) { + throw new AsyncException(AsyncException.ErrorType.INVALID_CONFIGURATION, 'Contact lastName must have at least one character for alias generation'); + } + + // Use first 4 characters of lastName if length is 4 or more, otherwise use full lastName + String lastName = contact.lastName.length() >= 4 ? contact.lastName.substring(0, 4) : contact.lastName; return contact.firstName.substring(0, 1) + lastName; - } -} \ No newline at end of file + } +}