Deferring emails with Action Scheduler (part 3)

In Part 2 of this tutorial, we rounded out our Action Scheduler-driven strategy for sending emails asynchronously. Once again, emails are just a handy example, and the same strategy could be applied to lots of other tasks. Let's revisit the ecommerce-based scenario we outlined in Part 1:

  1. Customer places an order.
  2. Order record is saved to the database, and probably lots of other behind-the-scenes work also happens here.
  3. An email is sent to the customer, summarizing the newly placed order.
  4. A further email is sent to the merchant, giving them the information they need (or just to alert them to the placement of this new order).
  5. Likely, yet more behind-the-scenes stuff now needs to happen (and, meantime, while all this is happening, the customer may be drumming their fingers, waiting to see a confirmation screen).

Using the solution we've been developing, we now simply push the email to a job queue and execution in the originating process can continue almost uninterrupted. Great! We don't know exactly when the emails will be sent—there could be a short delay or, in a highly tuned environment, they may be picked up and processed almost immediately—but it is probably reasonable to predict that, in most cases, they will still be sent one after another, without any kind of gap between sends.

This may be fine ... but what if, to prevent rate-limiting of some kind, we need to enforce a minimum delay of at least 10 seconds between sends? To solve this, we can take advantage of recurring actions. Here's some code showing how these work:

// Register a new recurring action: it should start as soon as possible, 
// then wait 10 seconds between tasks.
as_schedule_recurring_action( time(), 10, 'do_my_task', unique: true );

add_action( 'do_my_task', function () {
	// This function will, theoretically, be triggered every 10 seconds.
	// Or, in general, should *at least* have a 10 second (or greater)
	// gap between invocations.
} );

Hopefully the comments do a reasonable job of outlining what's happening. Let's poke at this approach a little more:

  • As one of the comments alludes to, we can't actually guarantee that the callback will be triggered every 10 seconds without fail (though if we are lucky enough to have full control over the production environment, we can get very close to this—check out this post to learn a little more about when and how Action Scheduler runs).
  • We can however be reasonably confident that there will be at least 10 seconds between each call (this time, the caveat is that it is possible for actions to be forced to run ahead-of-schedule).
  • Setting the $unique parameter to true is also worth commenting on, as it's a relatively new feature of Action Scheduler: this simply means there should only ever be one active instance of the action (in other words, it prevents duplicate tasks being added to the queue).
  • There is one other thing to consider here: this recurring action will be present, and will keep being called, even if there isn't actually any work (like sending an email) waiting to be processed.

The last point is quite important. Though the overhead an 'empty task' represents is minimal, it's still overhead. The ideal scenario is, perhaps, one where:

  • We use a recurring action to check in on and send any waiting emails.
  • If no more emails are queued up, it self-cancels.
  • If a new email needs to be sent, we restart the recurring action.

Let's put it all together

Up until now, we've used as_enqueue_async_action() to push work to the job queue, which can be understood as, "Process this single piece of work asynchronously, and do it as soon as possible." As described above, we need to replace it with its recurring equivalent which, in our case, will look something like this (again, I'm using the named arguments syntax here—feel free to rewrite it if you need to support older versions of PHP):

return (bool) as_schedule_recurring_action(
	timestamp:           time(),
	interval_in_seconds: 10,
	hook:                'my_async_emails_send_email',
	group:               'deferred-emails',
	unique:              true,
);

Notice we are no longer supplying the post ID of our stored email. Instead, our action callback (the send_emails() method) will try to fetch the next stored email, send it, then delete it. On the other hand, if there aren't any more emails waiting to be sent, it will self-cancel. Relevant fragment, to give a flavour of the intended structure:

$email_id = My_Stored_Emails::get_next_id();

if ( ! $email_id ) {
	self::unhook_self();
	return;
}

$email_array = My_Stored_Emails::get( $email_id );

if ( $email_array ) {
	wp_mail( ...$email_array );
	My_Stored_Emails::delete( $email_id );
}

The call to the currently undefined unhook_self() method deserves an extra note. What we need to do here is make a call to as_unschedule_all_actions() ... so why don't we just do that directly? It's because we are still within the running action, and it will have no effect. Instead, we need to wait until the current queue is finished, which we can do using some code like this:

private static function unhook_self(): void {
	add_action( 'action_scheduler_after_process_queue', function () {
		as_unschedule_all_actions(
			hook: 'my_async_emails_send_email',
			group: 'deferred-emails',
		);
	} );
}

Last but not least, we need to do the actual query to check for any waiting emails—represented above by the call to My_Stored_Emails::get_next_id()—and also implement the delete method (which will simply wrap the relevant WordPress API call). Since that's not the focus of this tutorial, though, I'll simply stop here and link to our final iteration, which you can take and tinker with as needed.


So concludes our little tutorial. I hope it has provided a practical insight into working with Action Scheduler, in addition to describing some of its more recent features (like unique actions)—and that you now feel better equipped to use it in projects of your own 🙂

You may also like:

Going native with WooCommerce sessions

WooCommerce comes complete with its own database-backed session handler. Information about the shopper, the contents of their cart, any flash...