Salesforce Robbie Duncan Salesforce Robbie Duncan

Testing Queueable Finalizers Beyond The Limits

What happens when you try to test Finalizers beyond the limits? Well it all goes wrong! Come and find out how

Finalizers on Queueables are fantastic. Finally we can catch the uncatchable. Even if your Queueable hits the governor limits and is terminated your Finalizer will still get run and can do something to handle the exception. A reasonably likely scenario is that the failed Queueable will be re-enqueued with some change to its scope so as it will complete.

An example Queueable is shown below

public with sharing class TestQueueable implements Queueable, Finalizer {
  private Callable function;
  private Integer scope;
  public TestQueueable(
    Callable function,
    Integer scope
  ) {
    this.function = function;
    this.scope = scope;
  }
  public execute(QueueableContext ctx) {
    System.attachFinalizer(this);
    Map<String, Object> params = new Map<String,Object> {
      ‘scope’ => scope
    };
   function.call(‘action’,params);
  }
  public execute(FinalizerContext ctx) {
    if (null != ctx.getException()) {
      System.enqueueJob(
        new TestQueueable(function, scope/2)
      );
    }
  }
}

As you can see this is about the most basic implementation of this pattern possible. As a simple form of dependency injection we use a Callable that will take a scope parameter which will control the amount of work done. If the callable throws we just retry with half the scope.

Like any good developer we now need to test this. A naive test might look something like this

@IsTest
private class TestQueueableTest {
@IsTest
private static void execute_whenNoException_doesNotReenqueue() {
Test.startTest();
System.enqueueJob(
new TestQueueable(new NoThrowCallable(),200);
);
Test.stopTest();
// Verify no re-enqueue
}

private NoThrowCallable implements Callable {
private call(action, args) {
}
}
}

This will test the happy path. In the real world we might want to have a mockable wrapper round System.enqueueJob to make this testing easier (and ot enqueue with no delay and appropriate depth control for initial enqueue). This would make the verification simple (with ApexMocks verify). I’ll consider that out of scope for this post as it does not impact the point.

But what about testing re-enqueue? Well that’s simple right - just throw an exception!

@IsTest
private class TestQueueableTest {
@IsTest
private static void execute_whenNoException_doesNotReenqueue() {
Test.startTest();
System.enqueueJob(
new TestQueueable(new ThrowCallable(),200);
);
Test.stopTest();
// Verifyre-enqueue
}

private ThrowCallable implements Callable {
private call(action, args) {
throw new MockException()
}
}
private MockException extends Exception {}
}

This won’t work. The exception will bubble to the top and be re-thrown at stopTest and the test will fail. Oh no! Ah, well that’s not a problem is it? We can catch exceptions like this

private class TestQueueableTest {
@IsTest
private static void execute_whenNoException_doesNotReenqueue() {
try {
Test.startTest();
System.enqueueJob(
new TestQueueable(new ThrowCallable(),200);
);
Test.stopTest();
} catch (Exception e) {
}
// Verifyre-enqueue
}

private ThrowCallable implements Callable {
private call(action, args) {
throw new MockException()
}
}
private MockException extends Exception {}
}

Now the test will complete and the verification section can run. Note that even without the try/catch in the test the Finalizer would run. We can verify this with debug logs. But what if we only want to re-enqueue on limits exceptions. That makes sense as we are reducing the scope to handle limits. Let’s say we altered our Queueable to look like this

public with sharing class TestQueueable implements Queueable, Finalizer {
  private Callable function;
  private Integer scope;
  public TestQueueable(
    Callable function,
    Integer scope
  ) {
    this.function = function;
    this.scope = scope;
  }
  public execute(QueueableContext ctx) {
    System.attachFinalizer(this);
    Map<String, Object> params = new Map<String,Object> {
      ‘scope’ => scope
    };
    try {
      function.call(‘action’,params);
    } catch (Exception e) {
      // Do something
    }
  }
  public execute(FinalizerContext ctx) {
    if (null != ctx.getException()) {
      System.enqueueJob(
        new TestQueueable(function, scope/2)
      );
    }
  }
}

Now any catchable Exception will be caught and the Finalizer won’t see the exception. But a limits exception cannot be caught and will cause re-enqueue. Great! But our previous test won’t work. We are throwing a catchable Exception. Let’s try again

private class TestQueueableTest {
@IsTest
private static void execute_whenNoException_doesNotReenqueue() {
try {
Test.startTest();
System.enqueueJob(
new TestQueueable(new ThrowCallable(),300);
);
Test.stopTest();
} catch (Exception e) {
}
// Verifyre-enqueue
}

private ThrowCallable implements Callable {
private call(action, args) {
Integer scope = args.get(‘scope’);
for (Integer i = 0;i<scope;i++) {
List<Contact> contacts = [Select id from Contact Limit 1];
}
}
}
}

So now if scope is large enough we’ll get a limits exception for too many SOQL queries. If it’s small enough we won’t. But this test can never succeed. The Limits exception will be re-thrown at Test.stopTest and cannot be caught. So the test will fail.

This means it is impossible to test finalizers with limit exceptions. Unless you know how! If so please get in touch 🙂

Read More