Deadline Migration

Axon Framework 5 has no built-in deadline scheduling. We have decided against this, as it is rather straightforward to schedule messages yourself. You can schedule a command or an event yourself by picking your desired scheduling library and invoke the CommandGateway or EventGateway to dispatch the message at your desired time. For more on how we envision message scheduling going forward, be sure to check out our Message Scheduling guide here.

However, we are aware you have perhaps already scheduled deadlines and want to move those to Axon Framework 5. To that end, we added axoniq-legacy that allows migrating already-scheduled deadlines, the same way it allows migrating running sagas. The deadline support has moved over into axoniq-legacy, and takes a slightly different turn whether the deadline was scheduled in a saga or in an aggregate. Please check the respective sections for details on either.

First, let’s take a look at the general approach to migrate your deadlines into Axon Framework 5:

  1. Add draining support. To drain your Axon Framework 4 deadlines in Axon Framework 5, add the axoniq-legacy dependency. This gives you all the DeadlineManager implementations, hooks, and configuration options. Configure the deadline manager so it reads what Axon Framework 4 stored, as described in Configuring the deadline manager.

  2. Migrate saga deadline handlers. Depending on axoniq-legacy is sufficient, so you’re all set! Be sure to migrate your sagas though.

  3. Migrate entity deadline handlers. You need to replace @DeadlineHandler annotations for @CommandHandler annotations in your aggregates. Be sure to check out Aggregate deadlines for more details on the exact structure.

  4. Stop scheduling new deadlines. For active sagas, the DeadlineManager can still be invoked. Just ensure you do not start any new sagas. For aggregates, you should remove the DeadlineManager entirely. Instead, schedule commands with your preferred scheduling tool yourself, as explained in more detail here.

  5. Wait for scheduled deadlines to drain. Monitor the deadline manager’s store for outstanding deadlines against the migrated entity type, and watch the count fall to zero.

  6. Retire the deadline manager once there are no more outstanding deadlines for the migrated entity. You can then remove all remaining usage of the DeadlineManager.

Automated code conversion can take care of much of this mechanically. See OpenRewrite code conversion for the available recipes and how to run them.

Configuring the deadline manager

axoniq-legacy ports the Quartz, JobRunr and db-scheduler deadline managers of Axon Framework 4. They read the deadlines Axon Framework 4 stored, and fire them in Axon Framework 5.

  • Match the Axon Framework 4 serializer. A deadline manager converts the stored payload, metadata and scope with its Converter, which has to match the serializer the Axon Framework 4 deadline manager used, including its configuration (such as a customized ObjectMapper). Axon Framework 4’s JobRunr and db-scheduler auto-configurations stored deadlines with the event serializer, not the general serializer. The Spring Boot auto-configurations of axoniq-legacy therefore build these managers with the EventConverter. Configure the event converter to match the Axon Framework 4 event serializer, or define your own DeadlineManager bean with another converter.

  • Quartz has no auto-configuration. Define a QuartzDeadlineManager yourself, with the ScopeAwareProvider and UnitOfWorkFactory of the configuration:

    @Bean
    public DeadlineManager deadlineManager(Scheduler scheduler,
                                           ScopeAwareProvider scopeAwareProvider,
                                           Configuration configuration) {
        return QuartzDeadlineManager.builder()
                                    .scheduler(scheduler)
                                    .scopeAwareProvider(scopeAwareProvider)
                                    .unitOfWorkFactory(configuration.getComponent(UnitOfWorkFactory.class))
                                    // the counterpart of the serializer your Axon Framework 4 deadline manager used
                                    .converter(new JacksonConverter())
                                    .build();
    }

    In Spring Boot, the Quartz Scheduler starts after the application has created its beans, so the deadline manager is ready before the first deadline fires. Without Spring, create the QuartzDeadlineManager before calling scheduler.start(). A deadline that fires before the manager exists fails without a retry, and Quartz deletes it, as in Axon Framework 4.

  • JobRunr cannot cancel all deadlines within a scope. JobRunrDeadlineManager.cancelAllWithinScope throws an UnsupportedOperationException, as it did in Axon Framework 4. Cancel JobRunr deadlines by their schedule identifier instead.

  • db-scheduler drops the scheduled events of Axon Framework 4. Axon Framework 4’s db-scheduler auto-configuration also registered the event scheduler’s AxonScheduledEvent task, which axoniq-legacy does not provide yet. On a db-scheduler table shared with Axon Framework 4 nodes, db-scheduler deletes all executions of a task no node knows, once the first one has been unresolved for deleteUnresolvedAfter (14 days by default). To keep your scheduled events, either let them fire before the last Axon Framework 4 node stops, or set a large finite deleteUnresolvedAfter on the scheduler, such as Duration.ofDays(36500). They then wait, unfired, until axoniq-legacy supports them.

Saga deadlines

A deadline scheduled from, and handled by, a saga needs no migration action apart from depending on axoniq-legacy. The axoniq-legacy module registers all required deadline-specific components for you, and ensures your saga deadlines keep working as they are. This makes it so that existing @DeadlineHandler annotated methods in a saga keep firing unchanged.

However, as Sagas are deprecated, just as Deadlines, you are expected to migrate those as well. Check out the Saga Migration for the full story, including what to do once every saga instance has drained and the legacy module is no longer needed.

Reading deadlines stored by Axon Framework 4

A persistent deadline manager (Quartz, db-scheduler or JobRunr) stores each pending deadline in serialized form. Axon Framework 4 wrote it with your Serializer. Axon Framework 5 reads it back with a Converter, and the formats must match:

  • The auto-configured db-scheduler and JobRunr deadline managers use the event Converter, which is Jackson-based by default.

  • A deadline manager you build yourself uses the Converter you pass to its converter(..) builder method.

Check which serializer your Axon Framework 4 application used for deadlines. If it set no axon.serializer.* properties and built no serializer itself, that was XStream. Reading XStream data with Jackson fails with Unexpected character ('<') before the deadline handler runs, and the scheduler retries the deadline without ever delivering it.

For XStream data, configure the XStreamConverter until the Axon Framework 4 deadlines have drained. For Jackson data, make sure your Converter uses the same ObjectMapper settings your JacksonSerializer had.

Aggregate deadlines

In Axon Framework 4, scheduling a deadline against an aggregate meant scheduling a reminder tied to that specific aggregate instance: once the deadline’s trigger time arrived, it fired directly onto a @DeadlineHandler method on that same instance, without going through a command.

Axon Framework 5 drops that direct delivery mechanism, for the same reason explained above: it is simple enough to achieve the same effect yourself, by scheduling a command that targets the entity instead. Going forward, what used to be "a deadline firing on an aggregate" becomes "a scheduled command handled by an entity", handled the same way as any other command.

axoniq-legacy helps you make that transition: it automatically drains every deadline Axon Framework 4 already scheduled against an aggregate, by translating it into a command and dispatching it through the CommandGateway, so you do not need to re-schedule anything yourself. This changes what the migrated entity needs: replace the aggregate’s @DeadlineHandler method with a @CommandHandler method named <aggregateType>.<deadlineName>, as shown below for an Order aggregate, first for a deadline scheduled with a payload, then for one scheduled without. This name keeps deadlines apart that share a payload type, and deadlines of different aggregate types that share a name. The aggregate type is the one Axon Framework 4 stored with the deadline: the aggregate’s simple class name, unless @Aggregate(type = …​) set another one. Keep that name in the command name, even when the migration renames the class.

  • Axon Framework 4

  • Axon Framework 5

@DeadlineHandler(deadlineName = "paymentDue")
public void onPaymentDue(PaymentDueReminder payload) {
    // ...
}
@CommandHandler(commandName = "Order.paymentDue")
void handle(PaymentDueReminder command) {
    // ...
}

A payload-less deadline is named the same way, carrying the deadline name as its String payload:

  • Axon Framework 4

  • Axon Framework 5

@DeadlineHandler(deadlineName = "accountExpired")
public void onAccountExpired() {
    // ...
}
@CommandHandler(commandName = "Order.accountExpired")
void handle(String deadlineName) {
    // ...
}

A handler migrated with axoniq-legacy 5.4.0 is named after the deadline’s payload type, such as @CommandHandler void handle(PaymentDueReminder command), or after the deadline name alone for a deadline without a payload, such as @CommandHandler(commandName = "accountExpired"). When no handler exists for <aggregateType>.<deadlineName>, axoniq-legacy dispatches the deadline again under that earlier name, so such a handler keeps receiving its deadlines, and logs a warning asking to rename it.

  • A deadline’s payload typically carries no usable entity identifier, since Axon Framework 4 resolved an aggregate’s identifier independently of its payload. axoniq-legacy resolves it instead, from the aggregate identifier it writes into the dispatched command’s metadata, but only for a command handler declared on an @EventSourcedEntity/@EventSourced entity. A handler in a separate component that loads the entity through @InjectEntity fails with an EntityIdResolutionException. A migrated state-stored aggregate or a declaratively configured entity module does not consult this fallback either, so its command handler payload needs a usable identifier of its own.

  • Resolving a non-String identifier through this fallback depends on the application’s registered Converter. The default, Jackson-based converter handles a type like Long out of the box, but not every identifier type round-trips through it without a dedicated conversion being registered: a type such as UUID needs a Converter able to parse an unquoted String into one.

Cancelling Axon Framework 4 deadlines from an entity

While Axon Framework 4 deadlines drain, an entity’s command handler may still need to cancel them. Declare a DeadlineManager parameter on the handler for that. It is bound to the handler’s processing context, so its calls take effect when the command’s processing commits, as they did in Axon Framework 4. A DeadlineManager held in a field, or a parameter declared as an implementation type such as QuartzDeadlineManager, acts immediately instead.

Axon Framework 5 entities have no current scope, so the DeadlineManager methods that take no ScopeDescriptor throw an IllegalStateException in an entity’s handler. Pass the aggregate’s scope explicitly, with the aggregate type and identifier the deadline was scheduled with:

@CommandHandler
void handle(PayOrder command, DeadlineManager deadlineManager) {
    deadlineManager.cancelAllWithinScope("paymentDue", new AggregateScopeDescriptor("Order", orderId));
    // ...
}

Delivery of a slow command

A fired aggregate deadline waits for its command’s result for 30 seconds at most. When that wait times out, the delivery fails, and the deadline manager retries the deadline as it retries any failed delivery. With a distributed command bus, the command may still be running on another node at that moment. The retry then sends the command again, while the first one may still commit, so the entity handles the deadline twice. Keep the command handlers of migrated deadlines well within this time, or make them idempotent.