Skip to main content

Apply Best Practices for Writing Platform Event Triggers

Learning Objectives

After completing this unit, you’ll be able to:

  • Write a platform event trigger that uses a feature to resume the trigger after limit exceptions.
  • Write a platform event trigger that uses a feature to retry the trigger when an exception is hit.
  • Use a trigger template as a basis for your platform event trigger.

After order events are published, an order fulfillment app receives the events in real time and can process the orders. The order fulfillment app can be outside of the Salesforce Platform, such as an external client that uses Pub/Sub API and calls an external system to fulfill the orders. Or the order app can be on the platform, such as an Apex platform event trigger. In this module, the Cloud Kicks order fulfillment app is a platform event trigger on Order_Event__e.

Note

Note

The advantage that event triggers have over regular Apex object triggers, like triggers on the Account or the Order object, is that platform event triggers run asynchronously in the background. They run in their own process and aren’t part of the transaction that published the event, and therefore can process high volumes of events more efficiently.

Make Your Platform Event Trigger More Resilient with Checkpoints

You can write an Apex trigger on a platform event or a change event to subscribe to real-time event messages. It’s important that you write robust triggers that are resilient when exceptions occur. A robust trigger can resume execution after an uncatchable exception, such as a limit exception, or unhandled exception occurs by using the setResumeCheckpoint method.

If an Apex limit exception occurs, such as a DML or SOQL query exception, it isn’t catchable in a try-catch block. Your code fails and the current batch of events that was delivered to the trigger is no longer available. Unprocessed events from that batch aren’t redelivered.

If a non-limit exception occurs, you can catch it using a try-catch block. You can handle the error and log it in the debug log, and continue processing events. However, if your trigger doesn't catch exceptions and handle them, the trigger stops execution after an exception and you can’t get the unprocessed events from the same event batch again.

To avoid losing the unprocessed batch of events when a limit or unhandled exception occurs, use the setResumeCheckpoint method. The setResumeCheckpoint method sets a checkpoint in the event stream from where the platform event trigger resumes execution in a new invocation.

Resuming a trigger in a new invocation helps with limit exceptions that are reset in a new invocation, or non-limit exceptions that are transient. If an exception is thrown, the checkpoint is used during the next execution of the trigger. Trigger processing resumes after the last successfully checkpointed event message. That way, you don’t lose the unprocessed events from the previous execution and can process those events in the next invocation of the trigger.

When the trigger resumes, the new batch starts with the event message after the one that you set with setResumeCheckpoint using the Replay ID. The events are resent in their original order based on the ReplayId field values, which are unchanged. The trigger processes the resent events and later batches sequentially. Also, in the new trigger invocation after resumption, the Apex governor limits are reset. As a result, the limit exception isn't hit immediately again.

This example trigger sets the replay ID of the last processed event message in each iteration. If an exception occurs, the trigger is fired again and resumes processing starting with the event message after the one with the set replay ID.

trigger ResumeEventProcessingTrigger on Order_Event__e (after insert) {
    for (Order_Event__e event : Trigger.New) {
        // Process the event message.
        // ...
        

        // Set the Replay ID of the last successfully processed event message.
        // If a limit is hit, the trigger refires and processing starts with the
        // event after the last one processed (the set Replay ID).
       

        EventBus.TriggerContext.currentContext().setResumeCheckpoint(event.replayId);
    }
}

For more information, see Resume a Platform Event Trigger After an Uncaught Exception in the Platform Events Developer Guide.

Handle Transient Errors by Retrying a Trigger

In addition to resuming a trigger with checkpoints, another way of handling errors is by retrying the trigger with the entire batch of events using EventBus.RetryableException. Retrying a trigger gives you another chance to process event messages when a transient error occurs or when waiting for a condition to change.

To retry the event trigger, throw EventBus.RetryableException. Events are resent after a small delay, which increases in subsequent retries.

The events are resent in their original order based on the ReplayId field values, which are unchanged. The trigger processes the resent events and later event batches sequentially. Resent events have the same field values as the original events. The resent batch of events can be larger. When the trigger is retried, the DML operations performed in the trigger before the retry are rolled back and no changes are saved.

You can run a trigger up to 10 times when it’s retried (the initial run plus nine retries). After the trigger is retried nine times, it moves to the error state and stops processing new events. For more information, see Retry Event Triggers with EventBus.RetryableException in the Platform Events Developer Guide.

This pseudo-code gives you an idea of how to throw EventBus.RetryableException and limit the number of retries. The trigger uses a try-catch block and throws EventBus.RetryableException in the catch block. This code assumes that the exception is transient and won't occur in future trigger invocations.

trigger ResendEventsTrigger on Order_Event__e (after insert) {
    try {
       for (Order_Event__e event : Trigger.New) {
         // Process platform event.
         // ...
       }
    } catch(Exception e) {
         System.debug('Caught exception: ' + e.getMessage() +
           ' Stack trace:' + e.getStackTrace());
         System.debug('Retrying the platform event trigger');
        // Retry the trigger up to 4 times and resend batch of events
        if (EventBus.TriggerContext.currentContext().retries < 4) {
            throw new EventBus.RetryableException(
                     'Retrying the platform event trigger.');
  }
}

Use a Platform Event Trigger Template

This platform event trigger is a model for how to process events in a resilient manner and combines checkpoints with the retryable exception. It calls setResumeCheckpoint() to handle exceptions, catches non-limit exceptions using a try-catch block, and refires the trigger when necessary with EventBus.RetryableException. The setResumeCheckpoint() method takes effect if at least one event was successfully processed and setResumeCheckpoint() is called at least once.

How the Trigger Handles Limit Exceptions

When handling limit exceptions, the trigger resumes in a new trigger invocation, which resets the limits.

  • If a limit exception occurs after at least one event was successfully processed and setResumeCheckpoint() was called, the trigger resumes in a new invocation after the last successfully processed event. The limits are reset, reducing the likelihood for the limit exception to reoccur immediately.
  • If a limit exception occurs before the first event was processed and setResumeCheckpoint() was called, the trigger can't catch the limit exception and the trigger stops execution and drops the entire batch of events. Limit exceptions typically occur after processing a few events, so this case isn’t expected to occur frequently.

How the Trigger Handles Non-limit Exceptions

Handling non-limit exceptions in a try-catch block helps with debugging as you can log the exception message. Also, if the non-limit exception is transient, the trigger can be retried with the entire batch of events to get another chance to process the events.

  • If a non-limit exception happens in the first iteration of the loop before setResumeCheckpoint() is called (processedEventsCounter == 0), the trigger catches the exception and throws EventBus.RetryableException in the catch block. The EventBus.RetryableException causes the trigger to refire with the whole original batch of events. It refires for a limited number of times. The refiring of the trigger helps with a transient exception that doesn’t reoccur in future trigger invocations. If the exception is recurring and the trigger retries have been exhausted, the first event is skipped and the loop continues with the next event in the batch.
  • If a non-limit exception happens after at least one event has been successfully processed (processedEventsCounter > 0), the exception is logged in the debug log. Next, the exception is rethrown to cause the trigger to exit and a new invocation to start due to the checkpoint that was set earlier. The new event batch starts with the last event that caused the exception. In this new invocation, if the exception reoccurs, the EventBus.RetryableException is called because it is the first event in the batch that the trigger processes. The EventBus.RetryableException causes the batch of events to be retried for a number of times. The refiring of the trigger helps with a transient exception that doesn't reoccur in future trigger invocations. If the exception is recurring and the trigger retries have been exhausted, the first event is skipped and the loop continues with the next event in the batch.
trigger EventTriggerTemplate on Order_Event__e (after insert) {
    Integer processedEventsCounter = 0;
    Boolean shouldContinueToProcess = true;
    

    for (Order_Event__e event : Trigger.New) {
        try {
            // Process event message.
            // ....
            

            // Set Replay ID after which to resume event processing
            // in new trigger execution.
            EventBus.TriggerContext.currentContext().setResumeCheckpoint(
                event.ReplayId);
            System.debug('Processed event with Replay ID: ' + event.ReplayId);
            processedEventsCounter++;
            

            if (shouldContinueToProcess != true) {
                // Resume after the last successfully processed event message
                // after the trigger stops running.
                // Exit for loop.
                break;
            }
        } catch(Exception e) {
            // This catch block works only for non-limit exceptions
            // because limit exceptions cannot be caught.
            //
            // If no events have been processed, throw new EventBus.RetryableException
            if (processedEventsCounter == 0) {
                 // Only throw RetryableException when the first event
                // is processed but before setResumeCheckpoint is called.
                if (EventBus.TriggerContext.currentContext().retries < 4) {
                	throw new EventBus.RetryableException();
                }
            } else {
                 // Else log the exception in the debug log. The trigger will
                 // resume from the checkpoint next time.
                 System.debug('An exception occurred: ' + e.getMessage());
                // Rethrow exception - trigger exits and resumes with this event
                // as the first event in the batch of the new invocation.
                throw e;
            }
        }
    }
}

Write a Trigger That Resumes After Unexpected Failures

Vijay wants to write a platform event trigger that receives and processes order events. He wants to ensure that his platform event trigger for subscribing to platform events is robust. It can recover from limit exceptions and can handle other exceptions. Vijay uses the trigger template in the “Use a Platform Event Trigger Template” section above and modifies it.

The OrderEventTrigger trigger is based on the platform event trigger template. It has been modified to force a limit exception to happen in test context by calling this utility method: PlatformEventsTestUtil.forceExceptionForTesting(). When an exception occurs in test context, you can verify that the checkpoint causes the trigger to resume after the exception. The trigger depends on a utility class, PlatformEventsTestUtil, which enables testing exceptions. It also depends on the PlatformEventsConstants class to store constants. To verify that the trigger resumes after exceptions, a test class is provided in the next section.

To add the classes the OrderEventTrigger in your Trailhead Playground:

  1. In the Developer Console, click New | Apex Class.
  2. For class name, enter PlatformEventsConstants.
  3. Replace the default code with this trigger body.
public class PlatformEventsConstants {
	public static final Integer MAX_RETRIES = 3;
}
  1. Repeat the same steps to add the PlatformEventsTestUtil class.
public class PlatformEventsTestUtil {
    private static Boolean shouldForceException = false;
    private static Boolean isCatchableException = false;
    private static Boolean isContinuous = false;
    private static Integer runCounter = 0;
    private static Integer exceptionThrownAtRun = 0;
    

    public class MyCustomException extends Exception {}
    

    // Throws a limit exception at a specific trigger run.
    // The test method calls this utility method.
    public static void throwLimitExceptionAtRun(Integer exThrownAtRun) {
        enableExceptionForTesting(false, exThrownAtRun, false);
    }
    

    // Throws a catchable exception at a specific trigger run.
    // The test method calls this utility method.
    public static void throwCatchableExceptionAtRun(Integer exThrownAtRun) {
        enableExceptionForTesting(true, exThrownAtRun, false);
    }
    

    // Throws a catchable exception starting at a specific trigger run
    // and keeps throwing it again.
    // The test method calls this utility method.
    public static void throwCatchableExceptionContinuousAtRun(Integer exThrownAtRun) {
        enableExceptionForTesting(true, exThrownAtRun, true);
    }
    

    // Sets up the test parameters.
    private static void enableExceptionForTesting(Boolean isCatchableEx,
                                                 Integer exThrownAtRun,
                                                 Boolean isCont) {
        if (Test.isRunningTest()) {
            shouldForceException = true;
            isCatchableException = isCatchableEx;
            isContinuous = isCont;
            runCounter = 0;
            exceptionThrownAtRun = exThrownAtRun;
        }
    }
    

    // Enables testing of exceptions in an Apex trigger.
    // Runs only in test context.
    // The Apex trigger calls this method.
    public static void forceExceptionForTesting() {
        if (Test.isRunningTest() && shouldForceException) {
            runCounter++;
            System.debug('runCounter=' + runCounter);
            

            Boolean shouldThrowException = (runCounter == exceptionThrownAtRun) ||
                (runCounter >= exceptionThrownAtRun && isContinuous);
            

            if (shouldThrowException) {
                if (isCatchableException) {
                    System.debug('Throwing a catchable exception.');
                    throw new MyCustomException();
                } else {
                    System.debug('Throwing a limit exception.');
                    throw new System.LimitException();
                }
            }
        }
    }
    

    // Cleans up the test parameters. The test method calls this method.
    public static void cleanUp() {
        shouldForceException = false;
        isCatchableException = false;
        isContinuous = false;
        runCounter = 0;
        exceptionThrownAtRun = 0;
    }
    

    public static Integer getRunCounter() {
        return runCounter;
    }
}
  1. Repeat the same steps to add the OrderEventTrigger class by choosing New | Apex Trigger.
trigger OrderEventTrigger on Order_Event__e (after insert) {
    private Integer processedEventsCounter = 0;
    

    for (Order_Event__e event : Trigger.New) {
        try {
            // Enable testing exceptions. Runs only in test context.
            PlatformEventsTestUtil.forceExceptionForTesting();
            

            // Process event message.
            // ...
            

            // Set Replay ID after which to resume event processing
            // in a new trigger execution.
            EventBus.TriggerContext.currentContext().setResumeCheckpoint(
                event.ReplayId);
            System.debug('Processed event with Replay ID: ' + event.ReplayId);
            processedEventsCounter++;
           

        } catch(Exception e) {
            // This catch block works only for non-limit exceptions
            // because limit exceptions cannot be caught.
            //
            // If no events have been processed, throw new EventBus.RetryableException
            if (processedEventsCounter == 0) {
                // Only throw RetryableException when the first event
                // is processed but before setResumeCheckpoint is called.
                if (EventBus.TriggerContext.currentContext().retries <
                        PlatformEventsConstants.MAX_RETRIES) {
                    throw new EventBus.RetryableException();
                }
            }
            // This block is reached when catchable exception is received and
            // one of these statements is true:
            // - At least one event was processed.
            // - No event was processed but the number of retries
            //   for RetryableException is exhausted.
            //
            // Log the exception in the debug log. The trigger will
            // resume from the checkpoint if a checkpoint was set.
            System.debug('An exception occurred in OrderEventTrigger: ' + e.getTypeName());
            

            // Rethrow exception: Trigger exits and resumes with this event
            // as the first event in the batch of the new invocation.
            throw e;
        }
    }
}

Test Trigger Resumption with an Apex Test

You can verify trigger resumption with an Apex test by calling the Test.getEventBus().deliver(); method as many times as you expect the batch of original and remaining test events to be delivered to the trigger. The deliver() test method causes the batch of events to be delivered to the trigger. If the trigger processes only a partial batch of events, call Test.getEventBus().deliver();again. The next deliver() call sends the remaining unprocessed events from the batch and causes the trigger to resume.

The OrderEventTriggerTest test class contains test methods that test various scenarios. The tests cover trigger resumption after non-catchable limit exceptions and catchable exceptions. Other tests verify retrying the trigger with the RetryableException. The EventBusSubscriber.Positionfield is used to verify the number of events processed. It is possible to do so in test context because the ReplayId starts from 1 and is incremented, which isn't the case in non-test context.

Add the OrderEventTriggerTest class in your Trailhead Playground.

@isTest
public class OrderEventTriggerTest {
    // This test causes the trigger to hit a non-catchable limit exception
    // on the third event.
    // Expected: The trigger resumes where it left off and processes the next events.
    @isTest
    static void testResumeAfterLimitException() {
        PlatformEventsTestUtil.throwLimitExceptionAtRun(3);
        try {
            // Publish 5 events.
            // First event batch should have 2 successfully processed events
            // because the exception occurs on the 3rd event.
            // Verify that trigger resumes and processes remaining events.
            publishAndDeliverEvents(5, 2);
        } finally {
            // Clean up test parameters so they don't affect other tests
            PlatformEventsTestUtil.cleanUp();
        }
    }
    

    // This test causes the trigger to hit a catchable exception on the second event.
    // Expected: The trigger resumes where it left off and processes the next events.
    @isTest
    static void testResumeAfterCatchableExceptionOnSecondEvent() {
        PlatformEventsTestUtil.throwCatchableExceptionAtRun(2);
        try {
            // Publish 5 events.
            // First event batch should have 1 successfully processed event
            // and setResumeCheckpoint was called.
            // The exception occurs on the 2nd event.
            // Verify that trigger resumes and processes remaining events.
            publishAndDeliverEvents(5, 1);
        } finally {
            // Clean up test parameters so they don't affect other tests
            PlatformEventsTestUtil.cleanUp();
        }
    }
    

	// This test causes the trigger to hit a catchable exception on the first event.
	// Expected: The trigger is retried with the entire batch of events.
    @isTest
    static void testRetryAfterCatchableExceptionOnFirstEvent() {
        PlatformEventsTestUtil.throwCatchableExceptionAtRun(1);
        try {
            // Publish 5 events.
            // First batch of events delivered is empty because
            // the execution jumps to the catch block.
            // Because setResumeCheckpoint has not been called yet,
            // the trigger will be retried with EventBus.RetryableException
            // with the entire batch of events.
            publishAndDeliverEvents(5, 0);
        } finally {
            // Clean up test parameters so they don't affect other tests
            PlatformEventsTestUtil.cleanUp();
        }
    }
    

    // This test causes the trigger to hit a catchable exception on the first event and
    // exhausts the number of retries.
    // Expected: The trigger is retried with the entire batch of events MAX_RETRIES + 1 times.
    // After the retries are exhausted, the trigger rethrows the exception.
    @isTest
    static void testRetryAfterCatchableExceptionOnFirstEventMaxRetriesReached() {
        PlatformEventsTestUtil.throwCatchableExceptionContinuousAtRun(1);
        try {
            publishEvents(2);
            

            // The trigger should run (max retry + 1) times
            for (Integer i = 1; i <= PlatformEventsConstants.MAX_RETRIES + 1; i++) {
                Test.getEventBus().deliver();
                Assert.areEqual(i, PlatformEventsTestUtil.getRunCounter(),
                                'Trigger should have retried.');
            }
            

            // Calling deliver more times won't cause the trigger to run,
            // since we're not throwing the Retryable exception anymore.
            Test.getEventBus().deliver();
            Assert.areEqual(PlatformEventsConstants.MAX_RETRIES + 1,
                            PlatformEventsTestUtil.getRunCounter(),
                            'Trigger should NOT have retried.');
        } finally {
            // Clean up test parameters so they don't affect other tests
            PlatformEventsTestUtil.cleanUp();
        }
    }
    

    // Helper method to publish events.
    private static void publishEvents(Integer totalEvents) {
        List<Order_Event__e> eventList = new List<Order_Event__e>();
        

        // Create test events. Number of test events to create is totalEvents.
        for(Integer i = 0; i < totalEvents; i++) {
            Order_Event__e event = (Order_Event__e)Order_Event__e.sObjectType.newSObject(
                null, true);
            event.Order_Id__c='dummyOrderId' + i;
            eventList.add(event);
        }
        

        // Publish all events
        EventBus.publish(eventList);
    }
    

    // Helper method to publish events and call deliver twice to verify
    // that the trigger resumes after an exception.
    private static void publishAndDeliverEvents(Integer totalEvents,
                                                Integer expectedFirstDeliveryCount) {
        publishEvents(totalEvents);
        

        // Deliver the batch of events
        Test.getEventBus().deliver();
        

        // Verify that the trigger processed expectedFirstDeliveryNum of events
        // because it processed only that many events before the exception
        // was hit.
        Assert.areEqual(expectedFirstDeliveryCount,
                        getTriggerPosition(), 'Unexpected number of events processed.');
                

        // Call deliver() again to deliver the remaining events in the batch
        // and cause the trigger resume after the exception.
        Test.getEventBus().deliver();
               

        // Verify that the trigger resumes and remaining events are processed
        // for the remaining events, so there are a total of totalEvents processed events.
        Assert.areEqual(totalEvents,
                        getTriggerPosition(),
                        'Unexpected number of events processed.');
                                                    

        getTriggerPosition();
    }
    

    // Helper method to return the ReplayId of the last processed event.
    // In test context, replayIDs start from 1 and are incremented.
    private static Integer getTriggerPosition() {
        Integer position = 0;
        

        EventBusSubscriber[] subscribers =
            [SELECT Name, Type, Position, Retries, LastError
             FROM EventBusSubscriber WHERE Topic='Order_Event__e'];
        

        for (EventBusSubscriber sub : subscribers) {
            if (sub.Name == 'OrderEventTrigger') {
                System.debug('sub.Position='+sub.Position);
                position = sub.Position;
            }
        }
        

        return position;
    }
}

Run the Test Class in the Developer Console in Your Trailhead Playground

  1. In the Developer Console, click Test | New Run.
  2. In the Test Classes column, select OrderEventTriggerTest.
  3. Select the test, and then click Run.
  4. Double-click the completed test run to open the results in detail view. You can verify that all tests passed and the code coverage of OrderEventTrigger is 100%.

You learned about features that help you write triggers that are resilient from failure. You can use checkpoints to resume a trigger after limit or unhandled exceptions. You can retry a trigger with a full batch of events to get another chance to process events. In addition to these techniques, you can also control the size of the trigger batch to help avoid hitting limits. You learn how to do that in the next unit.

Resources

Share your Trailhead feedback over on Salesforce Help.

We'd love to hear about your experience with Trailhead - you can now access the new feedback form anytime from the Salesforce Help site.

Learn More Continue to Share Feedback