class-events.php 15 KB
Newer Older
Erick Hitter's avatar
Erick Hitter committed
1
2
<?php

3
namespace Automattic\WP\Cron_Control;
Erick Hitter's avatar
Erick Hitter committed
4
5
6
7
8
9
10
11
12
13
14

class Events extends Singleton {
	/**
	 * PLUGIN SETUP
	 */

	/**
	 * Class properties
	 */
	const LOCK = 'run-events';

15
16
	private $concurrent_action_whitelist = array();

17
18
	private $running_event = null;

Erick Hitter's avatar
Erick Hitter committed
19
20
21
22
23
24
	/**
	 * Register hooks
	 */
	protected function class_init() {
		// Prime lock cache if not present
		Lock::prime_lock( self::LOCK );
25
26
27
28

		// Prepare environment as early as possible
		$earliest_action = did_action( 'muplugins_loaded' ) ? 'plugins_loaded' : 'muplugins_loaded';
		add_action( $earliest_action, array( $this, 'prepare_environment' ) );
29
30
31

		// Allow code loaded as late as the theme to modify the whitelist
		add_action( 'after_setup_theme', array( $this, 'populate_concurrent_action_whitelist' ) );
32
33
34
35
36
37
	}

	/**
	 * Prepare environment to run job
	 *
	 * Must run as early as possible, particularly before any client code is loaded
38
	 * This also runs before Core has parsed the request and set the \REST_REQUEST constant
39
40
	 */
	public function prepare_environment() {
41
		// Limit to plugin's endpoints
42
43
		$endpoint = get_endpoint_type();
		if ( false === $endpoint ) {
44
45
46
			return;
		}

47
		// Flag is used in many contexts, so should be set for all of our requests, regardless of the action
48
		define( 'DOING_CRON', true );
49
50

		// When running events, allow for long-running ones, and non-blocking trigger requests
51
		if ( REST_API::ENDPOINT_RUN === $endpoint ) {
52
53
54
			ignore_user_abort( true );
			set_time_limit( JOB_TIMEOUT_IN_MINUTES * MINUTE_IN_SECONDS );
		}
Erick Hitter's avatar
Erick Hitter committed
55
56
	}

57
58
59
60
61
62
63
64
65
66
67
68
69
70
	/**
	 * Allow certain events to be run concurrently
	 *
	 * By default, multiple events of the same action cannot be run concurrently, due to alloptions and other data-corruption issues
	 * Some events, however, are fine to run concurrently, and should be whitelisted for such
	 */
	public function populate_concurrent_action_whitelist() {
		$concurrency_whitelist = apply_filters( 'a8c_cron_control_concurrent_event_whitelist', array() );

		if ( is_array( $concurrency_whitelist ) && ! empty( $concurrency_whitelist ) ) {
			$this->concurrent_action_whitelist = $concurrency_whitelist;
		}
	}

Erick Hitter's avatar
Erick Hitter committed
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
	/**
	 * List events pending for the current period
	 */
	public function get_events() {
		$events = get_option( 'cron' );

		// That was easy
		if ( ! is_array( $events ) || empty( $events ) ) {
			return array( 'events' => null, );
		}

		// Simplify array format for further processing
		$events = collapse_events_array( $events );

		// Select only those events to run in the next sixty seconds
		// Will include missed events as well
		$current_events = $internal_events = array();
		$current_window = strtotime( sprintf( '+%d seconds', JOB_QUEUE_WINDOW_IN_SECONDS ) );

		foreach ( $events as $event ) {
			// Skip events whose time hasn't come
			if ( $event['timestamp'] > $current_window ) {
				continue;
			}

96
			// Skip events that don't have any callbacks hooked to their actions, unless their execution is requested
97
			if ( ! $this->action_has_callback_or_should_run_anyway( $event ) ) {
98
99
100
				continue;
			}

Erick Hitter's avatar
Erick Hitter committed
101
102
103
			// Necessary data to identify an individual event
			// `$event['action']` is hashed to avoid information disclosure
			// Core hashes `$event['instance']` for us
104
			$event_data_public = array(
Erick Hitter's avatar
Erick Hitter committed
105
106
107
108
109
110
111
				'timestamp' => $event['timestamp'],
				'action'    => md5( $event['action'] ),
				'instance'  => $event['instance'],
			);

			// Queue internal events separately to avoid them being blocked
			if ( is_internal_event( $event['action'] ) ) {
112
				$internal_events[] = $event_data_public;
Erick Hitter's avatar
Erick Hitter committed
113
			} else {
114
				$current_events[] = $event_data_public;
Erick Hitter's avatar
Erick Hitter committed
115
116
117
118
119
			}
		}

		// Limit batch size to avoid resource exhaustion
		if ( count( $current_events ) > JOB_QUEUE_SIZE ) {
120
			$current_events = $this->reduce_queue( $current_events );
Erick Hitter's avatar
Erick Hitter committed
121
122
		}

123
		// Combine with Internal Events and return necessary data to process the event queue
Erick Hitter's avatar
Erick Hitter committed
124
125
		return array(
			'events'   => array_merge( $current_events, $internal_events ),
126
			'endpoint' => get_rest_url( null, REST_API::API_NAMESPACE . '/' . REST_API::ENDPOINT_RUN ),
Erick Hitter's avatar
Erick Hitter committed
127
128
129
130
		);
	}

	/**
131
132
	 * Check that an event has a callback to run, and allow the check to be overridden
	 * Empty events are, by default, skipped and removed/rescheduled
Erick Hitter's avatar
Erick Hitter committed
133
	 *
134
135
136
	 * @param $event  array  Event data
	 *
	 * @return bool
Erick Hitter's avatar
Erick Hitter committed
137
	 */
138
139
140
141
142
	private function action_has_callback_or_should_run_anyway( $event ) {
		// Event has a callback, so let's get on with it
		if ( false !== has_action( $event['action'] ) ) {
			return true;
		}
Erick Hitter's avatar
Erick Hitter committed
143

144
145
146
147
		// Run the event anyway, perhaps because callbacks are added using the `all` action
		if ( apply_filters( 'a8c_cron_control_run_event_with_no_callbacks', false, $event ) ) {
			return true;
		}
Erick Hitter's avatar
Erick Hitter committed
148

149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
		// Remove or reschedule the empty event
		if ( false === $event['args']['schedule'] ) {
			wp_unschedule_event( $event['timestamp'], $event['action'], $event['args']['args'] );
		} else {
			$timestamp = $event['timestamp'] + ( isset( $event['args']['interval'] ) ? $event['args']['interval'] : 0 );
			wp_reschedule_event( $timestamp, $event['args']['schedule'], $event['action'], $event['args']['args'] );
			unset( $timestamp );
		}

		return false;
	}

	/**
	 * Trim events queue down to the limit set by JOB_QUEUE_SIZE
	 *
	 * @param $events  array  List of events to be run in the current period
	 *
	 * @return array
	 */
	private function reduce_queue( $events ) {
		// Loop through events, adding one of each action during each iteration
		$reduced_queue = array();
		$action_counts = array();

		$i = 1; // Intentionally not zero-indexed to facilitate comparisons against $action_counts members

175
		do {
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
			// Each time the events array is iterated over, move one instance of an action to the current queue
			foreach ( $events as $key => $event ) {
				$action = $event['action'];

				// Prime the count
				if ( ! isset( $action_counts[ $action ] ) ) {
					$action_counts[ $action ] = 0;
				}

				// Check and do the move
				if ( $action_counts[ $action ] < $i ) {
					$reduced_queue[] = $event;
					$action_counts[ $action ]++;
					unset( $events[ $key ] );
				}
			}

			// When done with an iteration and events remain, start again from the beginning of the $events array
			if ( empty( $events ) ) {
				break;
			} else {
				$i++;
				reset( $events );

				continue;
			}
202
		} while( $i <= 15 && count( $reduced_queue ) < JOB_QUEUE_SIZE && ! empty( $events ) );
Erick Hitter's avatar
Erick Hitter committed
203

204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
		/**
		 * IMPORTANT: DO NOT re-sort the $reduced_queue array from this point forward.
		 * Doing so defeats the preceding effort.
		 *
		 * While the events are now out of order with respect to timestamp, they're ordered
		 * such that one of each action is run before another of an already-run action.
		 * The timestamp mis-ordering is trivial given that we're only dealing with events
		 * for the current JOB_QUEUE_WINDOW_IN_SECONDS.
		 */

		// Finally, ensure that we don't have more than we need
		if ( count( $reduced_queue ) > JOB_QUEUE_SIZE ) {
			$reduced_queue = array_slice( $reduced_queue, 0, JOB_QUEUE_SIZE );
		}

		return $reduced_queue;
Erick Hitter's avatar
Erick Hitter committed
220
221
222
223
224
225
226
227
	}

	/**
	 * Execute a specific event
	 *
	 * @param $timestamp  int     Unix timestamp
	 * @param $action     string  md5 hash of the action used when the event is registered
	 * @param $instance   string  md5 hash of the event's arguments array, which Core uses to index the `cron` option
228
	 * @param $force      bool    Run event regardless of timestamp or lock status? eg, when executing jobs via wp-cli
Erick Hitter's avatar
Erick Hitter committed
229
230
231
	 *
	 * @return array|\WP_Error
	 */
232
	public function run_event( $timestamp, $action, $instance, $force = false ) {
Erick Hitter's avatar
Erick Hitter committed
233
234
		// Validate input data
		if ( empty( $timestamp ) || empty( $action ) || empty( $instance ) ) {
235
			return new \WP_Error( 'missing-data', __( 'Invalid or incomplete request data.', 'automattic-cron-control' ), array( 'status' => 400, ) );
Erick Hitter's avatar
Erick Hitter committed
236
237
		}

Erick Hitter's avatar
Erick Hitter committed
238
		// Ensure we don't run jobs ahead of time
239
		if ( ! $force && $timestamp > time() ) {
240
			return new \WP_Error( 'premature', sprintf( __( 'Job with identifier `%1$s` is not scheduled to run yet.', 'automattic-cron-control' ), "$timestamp-$action-$instance" ), array( 'status' => 403, ) );
Erick Hitter's avatar
Erick Hitter committed
241
242
243
		}

		// Find the event to retrieve the full arguments
244
245
246
247
248
249
		$event = get_event_by_attributes( array(
			'timestamp'     => $timestamp,
			'action_hashed' => $action,
			'instance'      => $instance,
			'status'        => Events_Store::STATUS_PENDING,
		) );
Erick Hitter's avatar
Erick Hitter committed
250
251

		// Nothing to do...
252
		if ( ! is_object( $event ) ) {
253
			return new \WP_Error( 'no-event', sprintf( __( 'Job with identifier `%1$s` could not be found.', 'automattic-cron-control' ), "$timestamp-$action-$instance" ), array( 'status' => 404, ) );
Erick Hitter's avatar
Erick Hitter committed
254
255
		}

256
257
		unset( $timestamp, $action, $instance );

258
259
		// Limit how many events are processed concurrently, unless explicitly bypassed
		if ( ! $force ) {
260
261
262
			// Prepare event-level lock
			$this->prime_event_action_lock( $event );

263
			if ( ! $this->can_run_event( $event ) ) {
264
				return new \WP_Error( 'no-free-threads', sprintf( __( 'No resources available to run the job with action action `%1$s` and arguments `%2$s`.', 'automattic-cron-control' ), $event->action, maybe_serialize( $event->args ) ), array( 'status' => 429, ) );
265
			}
266

267
			// Free locks should event throw uncatchable error
268
269
			$this->running_event = $event;
			add_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) );
Erick Hitter's avatar
Erick Hitter committed
270
271
		}

Erick Hitter's avatar
Erick Hitter committed
272
		// Mark the event completed, and reschedule if desired
273
		// Core does this before running the job, so we respect that
Erick Hitter's avatar
Erick Hitter committed
274
275
		$this->update_event_record( $event );

Erick Hitter's avatar
Erick Hitter committed
276
		// Run the event
277
278
279
		try {
			do_action_ref_array( $event->action, $event->args );
		} catch ( \Throwable $t ) {
280
			// Note that timeouts and memory exhaustion do not invoke this block
281
282
			// Instead, those locks are freed in `do_lock_cleanup_on_shutdown()`

283
284
			do_action( 'a8c_cron_control_event_threw_catchable_error', $event, $t );

285
286
			$return = array(
				'success' => false,
287
				'message' => sprintf( __( 'Callback for job with action `%1$s` and arguments `%2$s` raised a Throwable - %3$s in %4$s on line %5$d.', 'automattic-cron-control' ), $event->action, maybe_serialize( $event->args ), $t->getMessage(), $t->getFile(), $t->getLine() ),
288
289
			);
		}
Erick Hitter's avatar
Erick Hitter committed
290

Erick Hitter's avatar
Erick Hitter committed
291
		// Free locks for the next event, unless they weren't set to begin with
292
		if ( ! $force ) {
293
			// If we got this far, there's no uncaught error to handle
294
295
296
			$this->running_event = null;
			remove_action( 'shutdown', array( $this, 'do_lock_cleanup_on_shutdown' ) );

297
			$this->do_lock_cleanup( $event );
Erick Hitter's avatar
Erick Hitter committed
298
299
		}

300
		// Callback didn't trigger a Throwable, indicating it succeeded
301
302
303
304
305
306
307
308
		if ( ! isset( $return ) ) {
			$return = array(
				'success' => true,
				'message' => sprintf( __( 'Job with action `%1$s` and arguments `%2$s` executed.', 'automattic-cron-control' ), $event->action, maybe_serialize( $event->args ) ),
			);
		}

		return $return;
Erick Hitter's avatar
Erick Hitter committed
309
	}
310

311
312
313
314
315
	/**
	 * Prime the event-specific lock
	 *
	 * Used to ensure only one instance of a particular event, such as `wp_version_check` runs at one time
	 *
Erick Hitter's avatar
Erick Hitter committed
316
	 * @param $event object Event data
317
	 */
Erick Hitter's avatar
Erick Hitter committed
318
	private function prime_event_action_lock( $event ) {
319
		Lock::prime_lock( $this->get_lock_key_for_event_action( $event ), JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS );
320
321
	}

322
323
324
	/**
	 * Are resources available to run this event?
	 *
325
	 * @param object $event Event data
326
327
328
329
	 *
	 * @return bool
	 */
	private function can_run_event( $event ) {
330
331
332
		// Limit to one concurrent execution of a specific action by default
		$limit = 1;

333
334
		if ( isset( $this->concurrent_action_whitelist[ $event->action ] ) ) {
			$limit = absint( $this->concurrent_action_whitelist[ $event->action ] );
335
336
337
338
			$limit = min( $limit, JOB_CONCURRENCY_LIMIT );
		}

		if ( ! Lock::check_lock( $this->get_lock_key_for_event_action( $event ), $limit, JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS ) ) {
339
340
341
342
			return false;
		}

		// Internal Events aren't subject to the global lock
343
		if ( is_internal_event( $event->action ) ) {
344
345
346
347
			return true;
		}

		// Check if any resources are available to execute this job
Erick Hitter's avatar
Typo    
Erick Hitter committed
348
		// If not, the individual-event lock must be freed, otherwise it's deadlocked until it times out
349
		if ( ! Lock::check_lock( self::LOCK, JOB_CONCURRENCY_LIMIT ) ) {
350
			$this->reset_event_lock( $event );
351
352
353
354
355
356
357
			return false;
		}

		// Let's go!
		return true;
	}

358
	/**
359
360
	 * Free locks after event completes
	 *
361
	 * @param object $event Event data
362
363
364
	 */
	private function do_lock_cleanup( $event ) {
		// Lock isn't set when event is Internal, so we don't want to alter it
365
		if ( ! is_internal_event( $event->action ) ) {
366
367
			Lock::free_lock( self::LOCK );
		}
368
369

		// Reset individual event lock
370
371
372
373
374
375
		$this->reset_event_lock( $event );
	}

	/**
	 * Frees the lock for an individual event
	 *
376
	 * @param object $event Event data
377
378
379
380
	 *
	 * @return bool
	 */
	private function reset_event_lock( $event ) {
381
382
383
384
385
386
387
388
		$lock_key = $this->get_lock_key_for_event_action( $event );
		$expires  = JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS;

		if ( isset( $this->concurrent_action_whitelist[ $event->action ] ) ) {
			return Lock::free_lock( $lock_key, $expires );
		} else {
			return Lock::reset_lock( $lock_key, $expires );
		}
389
390
391
392
393
	}

	/**
	 * Turn the event action into a string that can be used with a lock
	 *
394
	 * @param object $event Event data
395
396
397
398
399
	 *
	 * @return string
	 */
	public function get_lock_key_for_event_action( $event ) {
		// Hashed solely to constrain overall length
400
		return md5( 'ev-' . $event->action );
401
402
	}

403
404
405
406
	/**
	 * Mark an event completed, and reschedule when requested
	 */
	private function update_event_record( $event ) {
407
		if ( false !== $event->schedule ) {
Erick Hitter's avatar
Erick Hitter committed
408
409
410
411
412
			// Re-implements much of the logic from `wp_reschedule_event()`
			$schedules = wp_get_schedules();
			$interval  = 0;

			// First, we try to get it from the schedule
413
414
			if ( isset( $schedules[ $event->schedule ] ) ) {
				$interval = $schedules[ $event->schedule ]['interval'];
Erick Hitter's avatar
Erick Hitter committed
415
416
417
418
			}

			// Now we try to get it from the saved interval, in case the schedule disappears
			if ( 0 == $interval ) {
419
				$interval = $event->interval;
Erick Hitter's avatar
Erick Hitter committed
420
421
			}

422
			// If we have an interval, update the existing event entry
Erick Hitter's avatar
Erick Hitter committed
423
424
425
			if ( 0 != $interval ) {
				// Determine new timestamp, according to how `wp_reschedule_event()` does
				$now           = time();
426
				$new_timestamp = $event->timestamp;
Erick Hitter's avatar
Erick Hitter committed
427
428
429
430
431
432
433
434
435

				if ( $new_timestamp >= $now ) {
					$new_timestamp = $now + $interval;
				} else {
					$new_timestamp = $now + ( $interval - ( ( $now - $new_timestamp ) % $interval ) );
				}

				// Build the expected arguments format
				$event_args = array(
436
437
					'schedule' => $event->schedule,
					'args'     => $event->args,
Erick Hitter's avatar
Erick Hitter committed
438
439
440
					'interval' => $interval,
				);

441
				// Update event store
442
				schedule_event( $new_timestamp, $event->action, $event_args, $event->ID );
443
444

				// If the event could be rescheduled, don't then delete it :)
445
				return;
Erick Hitter's avatar
Erick Hitter committed
446
			}
447
448
		}

449
		// Either event doesn't recur, or the interval couldn't be determined
450
		delete_event( $event->timestamp, $event->action, $event->instance );
451
	}
452
453

	/**
454
	 * If event execution throws uncatchable error, free locks
455
	 *
456
457
458
	 * Covers situations such as timeouts and memory exhaustion, which aren't \Throwable errors
	 *
	 * Under normal conditions, this callback isn't hooked to `shutdown`
459
460
461
462
463
464
	 */
	public function do_lock_cleanup_on_shutdown() {
		if ( is_null( $this->running_event ) ) {
			return;
		}

465
		do_action( 'a8c_cron_control_freeing_event_locks_after_uncaught_error', $this->running_event );
466

467
468
		$this->do_lock_cleanup( $this->running_event );
	}
Erick Hitter's avatar
Erick Hitter committed
469
470
471
}

Events::instance();