class-events.php 15.9 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
	const DISABLE_RUN_OPTION = 'a8c_cron_control_disable_run';

17
18
	private $concurrent_action_whitelist = array();

19
20
	private $running_event = null;

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

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

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

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

49
		// Flag is used in many contexts, so should be set for all of our requests, regardless of the action
50
		set_doing_cron();
51
52

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

59
60
61
62
63
64
65
66
67
68
69
70
71
72
	/**
	 * 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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
	/**
	 * 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;
			}

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

Erick Hitter's avatar
Erick Hitter committed
103
104
105
			// Necessary data to identify an individual event
			// `$event['action']` is hashed to avoid information disclosure
			// Core hashes `$event['instance']` for us
106
			$event_data_public = array(
Erick Hitter's avatar
Erick Hitter committed
107
108
109
110
111
112
113
				'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'] ) ) {
114
				$internal_events[] = $event_data_public;
Erick Hitter's avatar
Erick Hitter committed
115
			} else {
116
				$current_events[] = $event_data_public;
Erick Hitter's avatar
Erick Hitter committed
117
118
119
120
121
			}
		}

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

125
126
		// Combine with Internal Events
		// TODO: un-nest array, which is nested for legacy reasons
Erick Hitter's avatar
Erick Hitter committed
127
		return array(
128
			'events' => array_merge( $current_events, $internal_events ),
Erick Hitter's avatar
Erick Hitter committed
129
130
131
132
		);
	}

	/**
133
134
	 * 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
135
	 *
136
137
138
	 * @param $event  array  Event data
	 *
	 * @return bool
Erick Hitter's avatar
Erick Hitter committed
139
	 */
140
141
142
143
144
	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
145

146
147
148
149
		// 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
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
175
176
		// 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

177
		do {
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
			// 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;
			}
204
		} while( $i <= 15 && count( $reduced_queue ) < JOB_QUEUE_SIZE && ! empty( $events ) );
Erick Hitter's avatar
Erick Hitter committed
205

206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
		/**
		 * 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
222
223
224
225
226
227
228
229
	}

	/**
	 * 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
230
	 * @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
231
232
233
	 *
	 * @return array|\WP_Error
	 */
234
	public function run_event( $timestamp, $action, $instance, $force = false ) {
Erick Hitter's avatar
Erick Hitter committed
235
236
		// Validate input data
		if ( empty( $timestamp ) || empty( $action ) || empty( $instance ) ) {
237
			return new \WP_Error( 'missing-data', __( 'Invalid or incomplete request data.', 'automattic-cron-control' ), array( 'status' => 400, ) );
Erick Hitter's avatar
Erick Hitter committed
238
239
		}

Erick Hitter's avatar
Erick Hitter committed
240
		// Ensure we don't run jobs ahead of time
241
		if ( ! $force && $timestamp > time() ) {
242
			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
243
244
245
		}

		// Find the event to retrieve the full arguments
246
247
248
249
250
251
		$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
252
253

		// Nothing to do...
254
		if ( ! is_object( $event ) ) {
255
			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
256
257
		}

258
259
		unset( $timestamp, $action, $instance );

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

265
			if ( ! $this->can_run_event( $event ) ) {
266
				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, ) );
267
			}
268

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

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

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

285
286
			do_action( 'a8c_cron_control_event_threw_catchable_error', $event, $t );

287
288
			$return = array(
				'success' => false,
289
				'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() ),
290
291
			);
		}
Erick Hitter's avatar
Erick Hitter committed
292

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

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

302
		// Callback didn't trigger a Throwable, indicating it succeeded
303
304
305
306
307
308
309
310
		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
311
	}
312

313
314
315
316
317
	/**
	 * 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
318
	 * @param $event object Event data
319
	 */
Erick Hitter's avatar
Erick Hitter committed
320
	private function prime_event_action_lock( $event ) {
321
		Lock::prime_lock( $this->get_lock_key_for_event_action( $event ), JOB_LOCK_EXPIRY_IN_MINUTES * \MINUTE_IN_SECONDS );
322
323
	}

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

335
336
		if ( isset( $this->concurrent_action_whitelist[ $event->action ] ) ) {
			$limit = absint( $this->concurrent_action_whitelist[ $event->action ] );
337
338
339
340
			$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 ) ) {
341
342
343
344
			return false;
		}

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

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

		// Let's go!
		return true;
	}

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

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

	/**
	 * Frees the lock for an individual event
	 *
378
	 * @param object $event Event data
379
380
381
382
	 *
	 * @return bool
	 */
	private function reset_event_lock( $event ) {
383
384
385
386
387
388
389
390
		$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 );
		}
391
392
393
394
395
	}

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

405
406
407
408
	/**
	 * Mark an event completed, and reschedule when requested
	 */
	private function update_event_record( $event ) {
409
		if ( false !== $event->schedule ) {
Erick Hitter's avatar
Erick Hitter committed
410
411
412
413
414
			// 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
415
416
			if ( isset( $schedules[ $event->schedule ] ) ) {
				$interval = $schedules[ $event->schedule ]['interval'];
Erick Hitter's avatar
Erick Hitter committed
417
418
419
420
			}

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

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

				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(
438
439
					'schedule' => $event->schedule,
					'args'     => $event->args,
Erick Hitter's avatar
Erick Hitter committed
440
441
442
					'interval' => $interval,
				);

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

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

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

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

467
		do_action( 'a8c_cron_control_freeing_event_locks_after_uncaught_error', $this->running_event );
468

469
470
		$this->do_lock_cleanup( $this->running_event );
	}
471

472
473
474
	/**
	 * Return status of automatic event execution
	 *
475
	 * @return int 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume
476
477
	 */
	public function run_disabled() {
478
479
480
481
482
483
484
485
		$disabled = (int) get_option( self::DISABLE_RUN_OPTION, 0 );

		if ( $disabled <= 1 || $disabled > time() ) {
			return $disabled;
		}

		$this->update_run_status( 0 );
		return 0;
486
	}
487
488
489
490
491
492
493
494
495
496
497
498

	/**
	 * Set automatic execution status
	 *
	 * 0 if run is enabled, 1 if run is disabled indefinitely, otherwise timestamp when execution will resume
	 *
	 * @param int $new_status
	 * @return bool
	 */
	public function update_run_status( $new_status ) {
		$new_status = absint( $new_status );

Erick Hitter's avatar
Erick Hitter committed
499
		// Don't store a past timestamp
500
501
502
503
		if ( $new_status > 1 && $new_status < time() ) {
			return false;
		}

Erick Hitter's avatar
Erick Hitter committed
504
		// Nothing to do, but `update_option()` will return false
505
506
507
508
		if ( $new_status === $this->run_disabled() ) {
			return false;
		}

509
		return update_option( self::DISABLE_RUN_OPTION, $new_status );
510
	}
Erick Hitter's avatar
Erick Hitter committed
511
512
513
}

Events::instance();