├── .forceignore ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── Contributing.md ├── LICENSE ├── README.md ├── core └── classes │ ├── AsyncProcessor.cls │ ├── AsyncProcessor.cls-meta.xml │ ├── AsyncProcessorTests.cls │ └── AsyncProcessorTests.cls-meta.xml ├── example-app └── classes │ ├── ContactAsyncProcessor.cls │ ├── ContactAsyncProcessor.cls-meta.xml │ ├── ExampleCalloutProcessor.cls │ ├── ExampleCalloutProcessor.cls-meta.xml │ ├── ExampleCalloutProcessorTests.cls │ └── ExampleCalloutProcessorTests.cls-meta.xml ├── package.json └── sfdx-project.json /.forceignore: -------------------------------------------------------------------------------- 1 | # .forceignore v2 2 | **/jsconfig.json 3 | **/.eslintrc.json 4 | **/__tests__ 5 | **/__mockData__ 6 | **/README.md 7 | **/main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ 3 | .vim-force.com/ 4 | yarn.lock 5 | package-lock.json 6 | .sfdx/ 7 | .localdevserver/ 8 | debug.log 9 | tests/apex 10 | **/main/default/ 11 | coverage/ 12 | .sf/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | sfdx-project.json 2 | node_modules/ 3 | .sf/ 4 | .sfdx/ 5 | .husky/ 6 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "arrowParens": "avoid", 7 | "plugins": [ 8 | "prettier-plugin-apex" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": "*.{cls, apex}", 13 | "options": { 14 | "parser": "apex" 15 | } 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributions Welcome 2 | 3 | Please make sure you install this repo's dependencies using NPM or Yarn prior to submitting a pull request: 4 | 5 | ```bash 6 | yarn 7 | # or 8 | npm -i 9 | ``` 10 | 11 | Please note that contributions will be carefully vetted because this is a sample repo; in general, the idea is that this is to be used as a starting-off point for making your own customizations, and as such I cannot promise that all contributions will be accepted. 12 | 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 James Simone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Async Processor 2 | 3 | This repository showcases how to use the `AsyncProcessor` pattern to avoid having to create batch classes and/or queueable classes. 4 | 5 | Using this pattern greatly simplifies how asynchronous code is defined and run on platform. Simply: 6 | 7 | - clone the repository or copy/paste the [AsyncProcessor](../../blob/main/core/classes/AsyncProcessor.cls) and [AsyncProcessorTests](../../blob/main/core/classes/AsyncProcessorTests.cls) files to your org 8 | - extend the `AsyncProcessor`, defining code you'd like to have processed asynchronously: 9 | 10 | - also add any additional interfaces you need, like `Database.Stateful` (as is appropriate) 11 | 12 | ```java 13 | public class AsyncContactProcessorExample extends AsyncProcessor { 14 | protected override void innerExecute(List records) { 15 | List contacts = (List) records; 16 | // do whatever processing here 17 | } 18 | } 19 | ``` 20 | 21 | - and then in usage: 22 | 23 | ```java 24 | // within some other class: 25 | new AsyncContactProcessorExample().get('SELECT Id, Account.Name FROM Contact').kickoff(); 26 | // or, alternatively, if you have the records already: 27 | new AsyncContactProcessorExample().get(contacts).kickoff(); 28 | ``` 29 | 30 | For query-based usages, `AsyncProcessor` will automatically choose whether to batch or enqueue based on the default returned by `Limits.getLimitQueryRows()` - this can also be overridden by providing an alternative implementation of `protected virtual Integer getLimitToBatch()` for subclasses of `AsyncProcessor`. 31 | 32 | ## Further Examples 33 | 34 | Here's an example lowering the `getLimitToBatch()` amount from 50k to 10k records. This could be really useful if the data you're passing in might otherwise blow up the heap size limit. 35 | 36 | ```java 37 | public without sharing class LowerLimitAsyncProcessor { 38 | 39 | public override Integer getLimitToBatch() { 40 | return Limits.getLimitDmlRows(); 41 | } 42 | } 43 | ``` 44 | 45 | You can also look at [ContactAsyncProcessor](../../blob/main/example-app/classes/ContactAsyncProcessor.cls) for a short, complete example. 46 | -------------------------------------------------------------------------------- /core/classes/AsyncProcessor.cls: -------------------------------------------------------------------------------- 1 | public abstract without sharing class AsyncProcessor implements Database.AllowsCallouts, Database.Batchable, Database.RaisesPlatformEvents, Process { 2 | private Boolean getWasCalled = false; 3 | private Boolean hasBeenEnqueuedAsync = false; 4 | private String query; 5 | private AsyncProcessorQueueable queueable; 6 | 7 | protected List records; 8 | private final List chunkRecords = new List(); 9 | 10 | // hack to wrap what would have been a Queueable into a batch process: 11 | private static final String FALLBACK_QUERY = 'SELECT Id FROM Organization'; 12 | 13 | @TestVisible 14 | private static BatchApexErrorEvent firedErrorEvent; 15 | 16 | public interface Process { 17 | String kickoff(); 18 | } 19 | 20 | /** 21 | * Process interface-related methods 22 | */ 23 | public Process get(String query) { 24 | return this.getProcess(query?.toLowerCase(), null); 25 | } 26 | 27 | public Process get(List records) { 28 | return this.getProcess(null, records); 29 | } 30 | 31 | public String kickoff() { 32 | this.validate(); 33 | if (this.queueable != null && this.getCanEnqueue()) { 34 | return this.queueable.kickoff(); 35 | } 36 | return Database.executeBatch(this, this.getBatchChunkSize()); 37 | } 38 | 39 | /** 40 | * Batchable implementation methods, including overrideable "finish" method 41 | */ 42 | 43 | public Database.QueryLocator start(Database.BatchableContext bc) { 44 | return Database.getQueryLocator(this.query ?? FALLBACK_QUERY); 45 | } 46 | 47 | public void execute( 48 | Database.BatchableContext bc, 49 | List localRecords 50 | ) { 51 | this.hasBeenEnqueuedAsync = false; 52 | Integer chunkSize = this.getBatchChunkSize(); 53 | if (this.query == null && this.records != null) { 54 | while (this.records.size() > chunkSize) { 55 | this.chunkRecords.add(this.records.remove(this.records.size() - 1)); 56 | } 57 | } 58 | this.innerExecute(this.records ?? localRecords); 59 | if (this.chunkRecords.isEmpty() == false) { 60 | this.records = new List(this.chunkRecords); 61 | this.kickoff(); 62 | } 63 | } 64 | 65 | public virtual void finish(Database.BatchableContext bc) { 66 | } 67 | 68 | /** Subclasses define their async processing logic using an override for "innerExecute"**/ 69 | protected abstract void innerExecute(List records); 70 | 71 | /** Subclasses can override this method to ensure finish is only called once if recursive queueables are involved */ 72 | protected virtual Boolean isFinished() { 73 | return true; 74 | } 75 | 76 | protected virtual Integer getLimitToBatch() { 77 | return Limits.getLimitQueryRows(); 78 | } 79 | 80 | protected virtual Integer getBatchChunkSize() { 81 | return 2000; 82 | } 83 | 84 | protected virtual Integer getQueueableChunkSize() { 85 | return this.records?.size() ?? this.getBatchChunkSize(); 86 | } 87 | 88 | private void validate() { 89 | if (this.getWasCalled == false) { 90 | throw new AsyncException( 91 | 'Please call get() to retrieve the correct Process instance before calling kickoff' 92 | ); 93 | } else if ( 94 | System.isBatch() && 95 | this.queueable == null && 96 | this.chunkRecords.isEmpty() == false 97 | ) { 98 | this.queueable = new AsyncProcessorQueueable(this); 99 | this.chunkRecords.clear(); 100 | } 101 | } 102 | 103 | private Process getProcess(String query, List records) { 104 | this.getWasCalled = true; 105 | this.records = records; 106 | this.query = query; 107 | 108 | Integer recordCount = query == null 109 | ? records.size() 110 | : this.getRecordCount(query); 111 | Boolean shouldBatch = recordCount > this.getLimitToBatch(); 112 | Process process = this; 113 | if (shouldBatch == false && this.getCanEnqueue()) { 114 | process = new AsyncProcessorQueueable(this); 115 | } 116 | return process; 117 | } 118 | 119 | private Integer getRecordCount(String query) { 120 | String countQuery = query.replace( 121 | query.substringBeforeLast(' from '), 122 | 'select count() ' 123 | ); 124 | Integer possibleOrderStatement = query.lastIndexOfIgnoreCase('order by'); 125 | if (possibleOrderStatement > -1) { 126 | countQuery = countQuery.replace( 127 | query.substring(possibleOrderStatement), 128 | '' 129 | ); 130 | } 131 | return Database.countquery(countQuery); 132 | } 133 | 134 | private Boolean getCanEnqueue() { 135 | Integer currentQueueableCount = Limits.getQueueableJobs(); 136 | // Sync transactions can enqueue up to 50 processes 137 | // but only one Queueable can be started per async transaction 138 | if ( 139 | this.hasBeenEnqueuedAsync == false && 140 | currentQueueableCount < Limits.getLimitQueueableJobs() 141 | ) { 142 | this.hasBeenEnqueuedAsync = this.isAsync(); 143 | return true; 144 | } else if (this.isAsync()) { 145 | return currentQueueableCount < 1; 146 | } else { 147 | return true; 148 | } 149 | } 150 | 151 | private Boolean isAsync() { 152 | return System.isQueueable() || System.isBatch() || System.isFuture(); 153 | } 154 | 155 | private class AsyncProcessorQueueable implements Database.AllowsCallouts, System.Queueable, System.Finalizer, Process { 156 | private final AsyncProcessor processor; 157 | private Boolean hasFinalizerBeenAttached = false; 158 | private Boolean shouldRequeue = false; 159 | 160 | public AsyncProcessorQueueable(AsyncProcessor processor) { 161 | this.processor = processor; 162 | this.processor.queueable = this; 163 | } 164 | 165 | public String kickoff() { 166 | this.processor.validate(); 167 | if (this.processor.getCanEnqueue() == false) { 168 | return this.processor.kickoff(); 169 | } 170 | this.hasFinalizerBeenAttached = false; 171 | 172 | return System.enqueueJob(this); 173 | } 174 | 175 | public void execute(System.QueueableContext qc) { 176 | // once we've enqueued, it's fine to reset this flag 177 | this.processor.hasBeenEnqueuedAsync = false; 178 | 179 | if (this.hasFinalizerBeenAttached == false) { 180 | this.hasFinalizerBeenAttached = true; 181 | System.attachFinalizer(this); 182 | } 183 | if (this.processor.records == null && this.processor.query != null) { 184 | this.processor.records = Database.query(this.processor.query); 185 | } 186 | 187 | List splitRecords = this.splitProcessorRecords(); 188 | this.processor.innerExecute(this.processor.records); 189 | 190 | if (splitRecords.isEmpty() == false) { 191 | this.shouldRequeue = true; 192 | this.processor.records.clear(); 193 | this.processor.records.addAll(splitRecords); 194 | } else if (this.processor.isFinished()) { 195 | this.processor.finish(new QueueableToBatchableContext(qc)); 196 | } 197 | } 198 | 199 | public void execute(System.FinalizerContext fc) { 200 | switch on fc?.getResult() { 201 | when UNHANDLED_EXCEPTION { 202 | this.fireBatchApexErrorEvent(fc); 203 | } 204 | when else { 205 | if (this.shouldRequeue) { 206 | this.shouldRequeue = false; 207 | this.kickoff(); 208 | } 209 | } 210 | } 211 | } 212 | 213 | private List splitProcessorRecords() { 214 | List splitRecords = new List(); 215 | while ( 216 | this.processor.records.size() > this.processor.getQueueableChunkSize() 217 | ) { 218 | splitRecords.add( 219 | this.processor.records.remove(this.processor.records.size() - 1) 220 | ); 221 | } 222 | return splitRecords; 223 | } 224 | 225 | private void fireBatchApexErrorEvent(System.FinalizerContext fc) { 226 | String fullLengthJobScope = String.join(this.getRecordsInScope(), ','); 227 | Integer jobScopeLengthLimit = 40000; 228 | Integer textFieldLengthLimit = 5000; 229 | // initializing a BatchApexErrorEvent works as of Spring 23 230 | // but we can't promise it always will - use accordingly! 231 | BatchApexErrorEvent errorEvent = new BatchApexErrorEvent( 232 | AsyncApexJobId = fc.getAsyncApexJobId(), 233 | DoesExceedJobScopeMaxLength = fullLengthJobScope.length() > 234 | jobScopeLengthLimit, 235 | ExceptionType = fc.getException().getTypeName(), 236 | JobScope = this.getSafeSubstring( 237 | fullLengthJobScope, 238 | jobScopeLengthLimit 239 | ) 240 | .removeEnd(','), 241 | Message = this.getSafeSubstring( 242 | fc.getException().getMessage(), 243 | textFieldLengthLimit 244 | ), 245 | Phase = 'EXECUTE', 246 | StackTrace = this.getSafeSubstring( 247 | fc.getException().getStacktraceString(), 248 | textFieldLengthLimit 249 | ) 250 | ); 251 | 252 | Database.SaveResult publishResult = EventBus.publish(errorEvent); 253 | if (publishResult.isSuccess()) { 254 | firedErrorEvent = errorEvent; 255 | } 256 | } 257 | 258 | private List getRecordsInScope() { 259 | List scope = new List(); 260 | for ( 261 | Id recordId : new Map(this.processor.records).keySet() 262 | ) { 263 | scope.add(recordId); 264 | } 265 | return scope; 266 | } 267 | 268 | private String getSafeSubstring(String target, Integer maxLength) { 269 | return target.length() > maxLength 270 | ? target.substring(0, maxLength) 271 | : target; 272 | } 273 | } 274 | 275 | private class QueueableToBatchableContext implements Database.BatchableContext { 276 | private final Id jobId; 277 | 278 | public QueueableToBatchableContext(System.QueueableContext qc) { 279 | this.jobId = qc.getJobId(); 280 | } 281 | 282 | public Id getJobId() { 283 | return this.jobId; 284 | } 285 | 286 | public Id getChildJobId() { 287 | return null; 288 | } 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /core/classes/AsyncProcessor.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /core/classes/AsyncProcessorTests.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class AsyncProcessorTests extends AsyncProcessor { 3 | // normally an outer test class wouldn't be extending something 4 | // but a batch class cannot be defined by an inner class 5 | 6 | private static Integer batchLimit = Limits.getLimitQueryRows(); 7 | private static Integer executeCallCounter = 0; 8 | private static Boolean executeWasFired = false; 9 | private static Boolean finishWasFired = false; 10 | private static Integer queueableChunkLimit = 1; 11 | 12 | public override void finish(Database.BatchableContext bc) { 13 | finishWasFired = true; 14 | } 15 | 16 | protected override void innerExecute(List records) { 17 | executeCallCounter++; 18 | executeWasFired = true; 19 | } 20 | 21 | protected override Integer getLimitToBatch() { 22 | return batchLimit; 23 | } 24 | 25 | protected override Integer getQueueableChunkSize() { 26 | return queueableChunkLimit; 27 | } 28 | 29 | @IsTest 30 | static void throwsWhenGetIsNotCalled() { 31 | Exception ex; 32 | try { 33 | new AsyncProcessorTests().kickoff(); 34 | Assert.fail('Above process should throw'); 35 | } catch (Exception e) { 36 | ex = e; 37 | } 38 | 39 | Assert.isInstanceOfType(ex, AsyncException.class); 40 | Assert.areEqual( 41 | 'Please call get() to retrieve the correct Process instance before calling kickoff', 42 | ex.getMessage() 43 | ); 44 | } 45 | 46 | @IsTest 47 | static void doesNotThrowWhenMultipleKickoffsCalledLowerThanBatchSize() { 48 | List records = new List(); 49 | AsyncProcessor processor = new AsyncProcessorTests(); 50 | 51 | List processes = new List{ 52 | processor.get(records), 53 | processor.get(records) 54 | }; 55 | 56 | Test.startTest(); 57 | for (AsyncProcessor.Process process : processes) { 58 | process.kickoff(); 59 | } 60 | Assert.areEqual(2, Limits.getQueueableJobs()); 61 | Test.stopTest(); 62 | 63 | Assert.isTrue(executeWasFired); 64 | Assert.isTrue(finishWasFired); 65 | Assert.areEqual(2, executeCallCounter); 66 | } 67 | 68 | private virtual class FailProcessor extends AsyncProcessor { 69 | protected override void innerExecute(List records) { 70 | throw new IllegalArgumentException('Fail'); 71 | } 72 | } 73 | 74 | @IsTest 75 | static void properlyFiresErrorEventIfQueueableFails() { 76 | try { 77 | Test.startTest(); 78 | new FailProcessor().get(new List()).kickoff(); 79 | Test.stopTest(); 80 | Assert.fail('Exception should be thrown after stopTest is called'); 81 | } catch (System.IllegalArgumentException unused) { 82 | // unfortunately Limits.getPublishImmediateDML() is reset here 83 | // and Test.getEventBus() doesn't allow us to interact with the fired 84 | // event in any kind of meaningful way, so we have to store a reference 85 | // within AsyncProcessor to assert on 86 | Assert.isNotNull(AsyncProcessor.firedErrorEvent); 87 | Assert.areEqual( 88 | IllegalArgumentException.class.getName(), 89 | AsyncProcessor.firedErrorEvent.ExceptionType 90 | ); 91 | Assert.areEqual('Fail', AsyncProcessor.firedErrorEvent.Message); 92 | Assert.areEqual('EXECUTE', AsyncProcessor.firedErrorEvent.Phase); 93 | } 94 | // if you have a local trigger on BatchApexErrorEvent defined 95 | // you can call Test.getEventBus().deliver(); here to force 96 | // the second async context to finish, and perform assertions on 97 | // any logic designed in your handler for that event 98 | } 99 | 100 | @IsTest 101 | static void allowsBatchLimitToBeAdjusted() { 102 | batchLimit = 0; 103 | // here we have to actually do DML so that the batch start method 104 | // successfully passes data to the batch execute method 105 | insert new Account(Name = AsyncProcessorTests.class.getName()); 106 | 107 | Test.startTest(); 108 | new AsyncProcessorTests().get('SELECT Id FROM Account').kickoff(); 109 | Test.stopTest(); 110 | 111 | Assert.areEqual( 112 | 1, 113 | [ 114 | SELECT COUNT() 115 | FROM AsyncApexJob 116 | WHERE 117 | Status = 'Completed' 118 | AND JobType = 'BatchApexWorker' 119 | AND ApexClass.Name = :AsyncProcessorTests.class.getName() 120 | ] 121 | ); 122 | Assert.isTrue(executeWasFired); 123 | Assert.isTrue(finishWasFired); 124 | } 125 | 126 | @IsTest 127 | static void requeuesWhenRecordSizeOverQueueableLimit() { 128 | List contacts = new List{ 129 | new Contact(Id = '003000000000001'), 130 | new Contact(Id = '003000000000002') 131 | }; 132 | Assert.isTrue( 133 | contacts.size() > queueableChunkLimit, 134 | 'Test has started under wrong conditions' 135 | ); 136 | 137 | Test.startTest(); 138 | new AsyncProcessorTests().get(contacts).kickoff(); 139 | Test.stopTest(); 140 | 141 | Assert.areEqual(2, executeCallCounter); 142 | } 143 | 144 | @IsTest 145 | static void worksWithOrderBy() { 146 | Exception ex; 147 | 148 | try { 149 | Test.startTest(); 150 | new AsyncProcessorTests() 151 | .get('SELECT Id FROM Contact ORDER BY AccountId') 152 | .kickoff(); 153 | Test.stopTest(); 154 | } catch (Exception e) { 155 | ex = e; 156 | } 157 | 158 | Assert.isNull(ex); 159 | } 160 | 161 | @IsTest 162 | static void worksWithAllRows() { 163 | Exception ex; 164 | 165 | try { 166 | Test.startTest(); 167 | new AsyncProcessorTests().get('SELECT Id FROM Task ALL ROWS').kickoff(); 168 | Test.stopTest(); 169 | } catch (Exception e) { 170 | ex = e; 171 | } 172 | 173 | Assert.isNull(ex); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /core/classes/AsyncProcessorTests.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /example-app/classes/ContactAsyncProcessor.cls: -------------------------------------------------------------------------------- 1 | public class ContactAsyncProcessor extends AsyncProcessor { 2 | protected override void innerExecute(List records) { 3 | Map accountsToUpdate = new Map(); 4 | 5 | for (Contact con : (List) records) { 6 | accountsToUpdate.put( 7 | con.AccountId, 8 | new Account( 9 | Id = con.AccountId, 10 | Name = con.FirstName + ' ' + con.LastName 11 | ) 12 | ); 13 | } 14 | 15 | update accountsToUpdate.values(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example-app/classes/ContactAsyncProcessor.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /example-app/classes/ExampleCalloutProcessor.cls: -------------------------------------------------------------------------------- 1 | public virtual class ExampleCalloutProcessor extends AsyncProcessor { 2 | public Boolean overrideBatchLimit = false; 3 | 4 | protected override void innerExecute(List records) { 5 | while (records.isEmpty() == false) { 6 | System.debug('Making callout'); 7 | records.remove(0); 8 | HttpRequest req = new HttpRequest(); 9 | req.setMethod('GET'); 10 | req.setEndpoint('https://example.com'); 11 | new Http().send(req); 12 | } 13 | } 14 | 15 | protected override Integer getQueueableChunkSize() { 16 | return System.Limits.getLimitCallouts(); 17 | } 18 | 19 | protected override Integer getLimitToBatch() { 20 | return this.overrideBatchLimit ? 1 : super.getLimitToBatch(); 21 | } 22 | 23 | protected override Integer getBatchChunkSize() { 24 | return this.getQueueableChunkSize(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example-app/classes/ExampleCalloutProcessor.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /example-app/classes/ExampleCalloutProcessorTests.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class ExampleCalloutProcessorTests { 3 | @IsTest 4 | static void worksWithOverTwoHundredRecordsQueueable() { 5 | List records = new List(); 6 | for (Integer i = 0; i < 200; i++) { 7 | String currentIndexString = '' + i; 8 | records.add( 9 | new Account( 10 | Id = '001' + '0'.repeat(12 - currentIndexString.length()) + i, 11 | Name = 'bulkCallout' + i 12 | ) 13 | ); 14 | } 15 | Test.setMock(HttpCalloutMock.class, new ExampleCalloutProcessorMock()); 16 | 17 | Test.startTest(); 18 | new ExampleCalloutProcessor().get(records).kickoff(); 19 | Test.stopTest(); 20 | 21 | Assert.areEqual( 22 | 2, 23 | [ 24 | SELECT COUNT() 25 | FROM AsyncApexJob 26 | WHERE 27 | Status = 'Completed' 28 | AND JobType = 'Queueable' 29 | AND ApexClass.Name = 'AsyncProcessor' 30 | ], 31 | 'Expected to see 2 AsyncApexJob initiated by test.' 32 | ); 33 | } 34 | 35 | @IsTest 36 | static void worksWithOverTwoHundredRecordsBatch() { 37 | List records = new List(); 38 | for (Integer i = 0; i < 200; i++) { 39 | String currentIndexString = '' + i; 40 | records.add( 41 | new Account( 42 | Id = '001' + '0'.repeat(12 - currentIndexString.length()) + i, 43 | Name = 'bulkCallout' + i 44 | ) 45 | ); 46 | } 47 | Test.setMock(HttpCalloutMock.class, new ExampleCalloutProcessorMock()); 48 | 49 | Test.startTest(); 50 | ExampleCalloutProcessor proc = new ExampleCalloutProcessor(); 51 | proc.overrideBatchLimit = true; 52 | proc.get(records).kickoff(); 53 | Test.stopTest(); 54 | 55 | Assert.areEqual( 56 | 2, 57 | [ 58 | SELECT COUNT() 59 | FROM AsyncApexJob 60 | WHERE 61 | Status = 'Completed' 62 | AND ApexClass.Name IN ('AsyncProcessor', 'ExampleCalloutProcessor') 63 | AND JobType IN ('Queueable', 'BatchApexWorker') 64 | ], 65 | '' + 66 | [ 67 | SELECT COUNT(Id), ApexClass.Name className, JobType jobType 68 | FROM AsyncApexJob 69 | GROUP BY ApexClass.Name, JobType 70 | ] 71 | ); 72 | } 73 | 74 | private class ExampleCalloutProcessorMock implements System.HttpCalloutMock { 75 | public HttpResponse respond(HttpRequest req) { 76 | return new HttpResponse(); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /example-app/classes/ExampleCalloutProcessorTests.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61.0 4 | Active 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apex-async-processor", 3 | "version": "1.0.0", 4 | "description": "Example repository showing how to abstract away which async framework is being used within Salesforce in favor of dynamically using the most appropriate solution for the problem at hand.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/jamessimone/apex-async-processor" 8 | }, 9 | "author": "james.simone", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "prettier": "latest", 13 | "prettier-plugin-apex": "^2.1.4" 14 | }, 15 | "keywords": [ 16 | "salesforce", 17 | "async", 18 | "batch", 19 | "apex", 20 | "queueable" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "core", 5 | "default": true 6 | }, 7 | { 8 | "path": "example-app" 9 | } 10 | ], 11 | "namespace": "", 12 | "sfdcLoginUrl": "https://login.salesforce.com", 13 | "sourceApiVersion": "56.0" 14 | } --------------------------------------------------------------------------------