From f1ed9fc425dfb7ef76f6d62896f49979861d364f Mon Sep 17 00:00:00 2001 From: Robert O'Rourke Date: Mon, 5 Dec 2022 16:43:06 +0200 Subject: [PATCH] Avoid scheduling known events on front end Fixes #702 --- docs/scheduled-tasks.md | 60 ++++++++++++++++++++- inc/performance_optimizations/namespace.php | 33 ++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/docs/scheduled-tasks.md b/docs/scheduled-tasks.md index 01928cc5..801b5450 100644 --- a/docs/scheduled-tasks.md +++ b/docs/scheduled-tasks.md @@ -11,12 +11,68 @@ The integration with WordPress is seamless, so existing WordPress themes, plugin ## Creating Scheduled Tasks -Scheduled events work by triggering an action hook, effectively running `do_action( 'hook' )` at the scheduled time. The functions you need to run at the scheduled time should added to that hook using `add_action()`. +Scheduled tasks, also known as "events", work by triggering an action hook, effectively running `do_action( 'hook' )` at the scheduled time. The functions you need to run at the scheduled time should added to that hook using `add_action()`. Events can be one-off or recurring. **Note**: Scheduling a one-off event to occur within 10 minutes of an existing event with the same hook and the same arguments will fail in order to prevent the accidental scheduling of duplicate events. This does not affect the scheduling of recurring events. +### Example Recurring Scheduled Event + +A typical pattern for scheduling tasks is to check if the task is already scheduled, and if not to schedule it. This is particularly useful for recurring events. + +```php +// Schedule an hourly process on the admin_init hook. +add_action( 'admin_init', function () { + if ( ! wp_next_scheduled( 'do_background_process' ) ) { + wp_schedule_event( time(), 'hourly', 'do_background_process' ); + } +} ); + +// Add a callback to the hook we just scheduled. +add_action( 'do_background_process', function () { + // ... Background process code goes here. +} ); +``` + +### Example Single Event + +When you only need a one-off event like some post-processing that takes a long time, you should schedule it based on a specific user action. The following example schedules an event when saving a post: + +```php +add_action( 'save_post', function ( $post_id ) { + // Schedule a background task and pass the post ID as an argument. + wp_schedule_single_event( time(), 'do_background_process', [ $post_id ] ); +} ); + +// Add a callback to the hook we just scheduled. +add_action( 'do_background_process', function ( $post_id ) { + // ... Background process code goes here and can use $post_id. +} ); +``` + +### Best Practices + +- Avoid scheduling events on front end requests, especially for non logged in users +- Use the `altis.migrate` or `admin_init` action hooks for scheduling recurring events +- Only schedule single events on a specific user action +- Use scheduled events to offload time consuming work and speed up the app for your users + +### Dealing With Third-Party Events + +It's common practice for WordPress plugins to schedule their events on the `init` hook. In a standard WordPress set up this is typically fine, as the scheduled events are stored in the Options table and autoloaded. This doesn't work on a multi-server architecture so Altis uses Cavalcade to handle background tasks. + +This means that each call to `wp_next_scheduled()` is a database lookup, rather than there being one lookup. Coupled with request latency this can cause unnecessary requests on the front end of the site and slower performance, particularly for logged in users. + +Use the `altis.cloud.admin_only_events` filter to force specific event hooks to only run in the admin context: + +```php +add_filter( 'altis.cloud.admin_only_events', function ( array $events ) : array { + $events[] = 'third_party_plugin_event_hook'; + return $events; +} ); +``` + ### Intervals Recurring events need a named interval. Out of the box these intervals are `hourly`, `twicedaily`, `daily` and `weekly`. @@ -62,7 +118,7 @@ This is the same as `wp_schedule_event()` but will trigger the event only once. **`wp_next_scheduled( string $hook, array $args = [] )`** -This function should be used to check if an event for the given hook and set of arguments has already been scheduled. If one has it will return the timestamp for the next occurrence. +This function should be used to check if an event for the given hook and set of arguments has already been scheduled. If one exists it will return the timestamp for the next occurrence. **`wp_unschedule_event( int $timestamp, string $hook, array $args = [], bool $wp_error = false )`** diff --git a/inc/performance_optimizations/namespace.php b/inc/performance_optimizations/namespace.php index 8369ea8b..64243890 100644 --- a/inc/performance_optimizations/namespace.php +++ b/inc/performance_optimizations/namespace.php @@ -14,6 +14,9 @@ */ function bootstrap() { increase_set_time_limit_on_async_upload(); + + // Avoid DB requests to Cavalcade on the front end. + add_filter( 'pre_get_scheduled_event', __NAMESPACE__ . '\\schedule_events_in_admin', 1, 2 ); } /** @@ -42,3 +45,33 @@ function increase_set_time_limit_on_async_upload() { } set_time_limit( 120 ); } + +/** + * Only schedule known events in the admin to avoid extra db requests on front end. + * + * @param null|false|object $pre Value to return instead. Default null to continue retrieving the event. + * @param string $hook Action hook of the event. + * @return null|false|object Value to return instead. Default null to continue retrieving the event. + */ +function schedule_events_in_admin( $pre, string $hook ) { + $admin_only_hooks = [ + 'wp_site_health_scheduled_check', + 'wp_privacy_delete_old_export_files', + 'wp_https_detection', + ]; + + /** + * Filter the scheduled event hooks to only fire in the admin context. This + * is useful for avoiding database lookups on front end requests that are not + * needed. + * + * @param array $backend_only_hooks The hook names to only run in the admin context. + */ + $admin_only_hooks = apply_filters( 'altis.cloud.admin_only_events', $admin_only_hooks ); + + if ( ! in_array( $hook, $admin_only_hooks, true ) || is_admin() ) { + return $pre; + } + + return true; +}