class-cron-options-cpt.php 11.7 KB
Newer Older
1
<?php
2

3
namespace Automattic\WP\Cron_Control;
4

5
class Cron_Options_CPT extends Singleton {
6
7
8
9
10
11
12
	/**
	 * PLUGIN SETUP
	 */

	/**
	 * Class properties
	 */
Erick Hitter's avatar
Erick Hitter committed
13
14
	const LOCK = 'create-jobs';

15
	const POST_TYPE             = 'a8c_cron_ctrl_event';
16
	const POST_STATUS_PENDING   = 'inherit';
17
	const POST_STATUS_COMPLETED = 'trash';
18

19
20
	const CACHE_KEY             = 'a8c_cron_ctrl_option';

21
22
	private $posts_to_clean = array();

23
24
	private $option_before_unscheduling = null;

25
26
27
	/**
	 * Register hooks
	 */
28
	protected function class_init() {
29
		// Data storage
30
		add_action( 'init', array( $this, 'register_post_type' ) );
31

Erick Hitter's avatar
Erick Hitter committed
32
33
34
		// Lock for post insertion, to guard against endless event creation when `wp_next_scheduled()` is misused
		Lock::prime_lock( self::LOCK );

35
36
37
		// Option interception
		add_filter( 'pre_option_cron', array( $this, 'get_option' ) );
		add_filter( 'pre_update_option_cron', array( $this, 'update_option' ), 10, 2 );
38
39
40
41
42
43
	}

	/**
	 * Register a private post type to store cron events
	 */
	public function register_post_type() {
44
		register_post_type( self::POST_TYPE, array(
45
46
47
48
49
			'label'               => 'Cron Events',
			'public'              => false,
			'rewrite'             => false,
			'export'              => false,
			'exclude_from_search' => true,
50
		) );
51
52
53

		// Clear caches for any manually-inserted posts, lest stale caches be used
		if ( ! empty( $this->posts_to_clean ) ) {
54
			foreach ( $this->posts_to_clean as $index => $post_to_clean ) {
55
				clean_post_cache( $post_to_clean );
56
				unset( $this->posts_to_clean[ $index ] );
57
58
			}
		}
59
60
61
62
63
	}

	/**
	 * PLUGIN FUNCTIONALITY
	 */
64
65
66
67

	/**
	 * Override cron option requests with data from CPT
	 */
68
	public function get_option() {
69
70
71
72
73
74
75
76
		// Use cached value for reads, except when we're unscheduling and state is important
		$cached_option = wp_cache_get( self::CACHE_KEY, null, true );

		if ( ! $this->is_unscheduling() && false !== $cached_option ) {
			return $cached_option;
		}

		// Start building a new cron option
77
78
79
80
81
		$cron_array = array(
			'version' => 2, // Core versions the cron array; without this, events will continually requeue
		);

		// Get events to re-render as the cron option
82
83
84
85
86
		$page  = 1;

		do {
			$jobs_posts = $this->get_jobs( array(
				'post_type'        => self::POST_TYPE,
87
				'post_status'      => self::POST_STATUS_PENDING,
88
89
90
91
92
93
94
95
96
97
98
				'suppress_filters' => false,
				'posts_per_page'   => 100,
				'paged'            => $page,
				'orderby'          => 'date',
				'order'            => 'ASC',
			) );

			// Nothing more to add
			if ( empty( $jobs_posts ) ) {
				break;
			}
99

100
			$page++;
101

102
103
104
105
106
107
			// Something's probably wrong if a site has more than 1,500 pending cron actions
			if ( $page > 15 ) {
				do_action( 'a8c_cron_control_stopped_runaway_cron_option_rebuild' );
				break;
			}

108
109
110
111
			// Loop through results and built output Core expects
			if ( ! empty( $jobs_posts ) ) {
				foreach ( $jobs_posts as $jobs_post ) {
					$timestamp = strtotime( $jobs_post->post_date_gmt );
112

113
114
115
116
					$job_args = maybe_unserialize( $jobs_post->post_content_filtered );
					if ( ! is_array( $job_args ) ) {
						continue;
					}
117

118
119
120
					$action   = $job_args['action'];
					$instance = $job_args['instance'];
					$args     = $job_args['args'];
121

122
123
124
125
					$cron_array[ $timestamp ][ $action ][ $instance ] = array(
						'schedule' => $args['schedule'],
						'args'     => $args['args'],
					);
126

127
128
129
130
131
					if ( isset( $args['interval'] ) ) {
						$cron_array[ $timestamp ][ $action ][ $instance ]['interval'] = $args['interval'];
					}

				}
132
			}
133
		} while( true );
134

135
136
		// Re-sort the array just as Core does when events are scheduled
		// Ensures events are sorted chronologically
137
		uksort( $cron_array, 'strnatcasecmp' );
138

139
140
141
142
143
144
145
		// If we're unscheduling an event, hold onto the previous value so we can identify what's unscheduled
		if ( $this->is_unscheduling() ) {
			$this->option_before_unscheduling = $cron_array;
		} else {
			$this->option_before_unscheduling = null;
		}

146
147
148
		// Cache the results, bearing in mind that they won't be used during unscheduling events
		wp_cache_set( self::CACHE_KEY, $cron_array, null, 1 * \HOUR_IN_SECONDS );

149
150
151
152
		return $cron_array;
	}

	/**
153
	 * Handle requests to update the cron option
154
155
156
157
	 *
	 * By returning $old_value, `cron` option won't be updated
	 */
	public function update_option( $new_value, $old_value ) {
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
		if ( $this->is_unscheduling() ) {
			$this->unschedule_job( $new_value, $this->option_before_unscheduling );
		} else {
			$this->convert_option( $new_value );
		}

		return $old_value;
	}

	/**
	 * Delete jobs that are unscheduled using `wp_unschedule_event()`
	 */
	private function unschedule_job( $new_value, $old_value ) {
		$jobs = $this->find_unscheduled_jobs( $new_value, $old_value );

		foreach ( $jobs as $job ) {
174
			$this->mark_job_completed( $job['timestamp'], $job['action'], $job['instance'] );
175
176
177
178
179
180
181
		}
	}

	/**
	 * Save cron events in CPT
	 */
	private function convert_option( $new_value ) {
182
		if ( is_array( $new_value ) && ! empty( $new_value ) ) {
183
184
185
			$events = collapse_events_array( $new_value );

			foreach ( $events as $event ) {
186
				$job_exists = $this->job_exists( $event['timestamp'], $event['action'], $event['instance'] );
187
188

				if ( ! $job_exists ) {
189
					$this->create_job( $event['timestamp'], $event['action'], $event['args'] );
190
191
192
193
194
195
196
197
198
				}
			}
		}
	}

	/**
	 * PLUGIN UTILITY METHODS
	 */

199
200
	/**
	 * Retrieve list of jobs, respecting whether or not the CPT is registered
201
	 *
202
	 * Uses a direct query to avoid stale caches that result in duplicate events
203
204
	 */
	private function get_jobs( $args ) {
205
		global $wpdb;
206

207
		$orderby = 'date' === $args['orderby'] ? 'post_date' : $args['orderby'];
208

209
210
211
212
213
		if ( isset( $args['paged'] ) ) {
			$paged  = max( 0, $args['paged'] - 1 );
			$offset = $paged * $args['posts_per_page'];
		} else {
			$offset = 0;
214
		}
215
216

		return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type = %s AND post_status = %s ORDER BY %s %s LIMIT %d,%d;", $args['post_type'], $args['post_status'], $orderby, $args['order'], $offset, $args['posts_per_page'] ), 'OBJECT' );
217
218
	}

219
	/**
220
	 * Check if a job post exists
221
222
	 *
	 * Uses a direct query to avoid stale caches that result in duplicate events
223
	 */
224
225
226
	private function job_exists( $timestamp, $action, $instance, $return_id = false ) {
		global $wpdb;

227
		 $exists = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND post_status = %s LIMIT 1;", $this->event_name( $timestamp, $action, $instance ), self::POST_TYPE, self::POST_STATUS_PENDING ) );
228
229
230
231
232
233

		if ( $return_id ) {
			return empty( $exists ) ? 0 : (int) array_shift( $exists );
		} else {
			return ! empty( $exists );
		}
234
235
236
237
	}

	/**
	 * Create a job post, respecting whether or not Core is ready for CPTs
238
239
	 *
	 * `wp_insert_post()` can't be called as early as we need, in part because of the capabilities checks Core performs
240
	 */
241
	public function create_job( $timestamp, $action, $args ) {
Erick Hitter's avatar
Erick Hitter committed
242
243
244
245
246
		// Limit how many events to insert at once
		if ( ! Lock::check_lock( self::LOCK, 5 ) ) {
			return false;
		}

247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
		// Build minimum information needed to create a post
		$instance = md5( serialize( $args['args'] ) );

		$job_post = array(
			'post_title'            => $this->event_title( $timestamp, $action, $instance ),
			'post_name'             => $this->event_name( $timestamp, $action, $instance ),
			'post_content_filtered' => maybe_serialize( array(
				'action'   => $action,
				'instance' => $instance,
				'args'     => $args,
			) ),
			'post_date'             => date( 'Y-m-d H:i:s', $timestamp ),
			'post_date_gmt'         => date( 'Y-m-d H:i:s', $timestamp ),
			'post_type'             => self::POST_TYPE,
			'post_status'           => self::POST_STATUS_PENDING,
		);

264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
		// If called before `init`, we need to insert directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
			wp_insert_post( $job_post );
		} else {
			global $wpdb;

			// Additional data needed to manually create a post
			$job_post = wp_parse_args( $job_post, array(
				'post_author'       => 0,
				'comment_status'    => 'closed',
				'ping_status'       => 'closed',
				'post_parent'       => 0,
				'post_modified'     => current_time( 'mysql' ),
				'post_modified_gmt' => current_time( 'mysql', true ),
			) );

			// Some sanitization in place of `sanitize_post()`, which we can't use this early
			foreach ( array( 'post_title', 'post_name', 'post_content_filtered' ) as $field ) {
				$job_post[ $field ] = sanitize_text_field( $job_post[ $field ] );
			}

			// Duplicate some processing performed in `wp_insert_post()`
			$charset = $wpdb->get_col_charset( $wpdb->posts, 'post_title' );
			if ( 'utf8' === $charset ) {
				$job_post['post_title'] = wp_encode_emoji( $job_post['post_title'] );
			}

			$job_post = wp_unslash( $job_post );

			// Set this so it isn't empty, even though it serves us no purpose
294
			$job_post['guid'] = esc_url( add_query_arg( self::POST_TYPE, $job_post['post_name'], home_url( '/' ) ) );
295
296
297
298
299
300
301
302
303

			// Create the post
			$inserted = $wpdb->insert( $wpdb->posts, $job_post );

			// Clear caches for new posts once the post type is registered
			if ( $inserted ) {
				$this->posts_to_clean[] = $wpdb->insert_id;
			}
		}
Erick Hitter's avatar
Erick Hitter committed
304

305
306
307
		// Delete internal cache
		wp_cache_delete( self::CACHE_KEY );

Erick Hitter's avatar
Erick Hitter committed
308
309
		// Allow more events to be created
		Lock::free_lock( self::LOCK );
310
311
	}

312
	/**
313
314
315
	 * Mark an event's CPT entry as completed
	 *
	 * Trashed posts will be cleaned up by an internal job
316
317
318
319
320
321
322
	 *
	 * @param $timestamp  int     Unix timestamp
	 * @param $action     string  name of action used when the event is registered (unhashed)
	 * @param $instance   string  md5 hash of the event's arguments array, which Core uses to index the `cron` option
	 *
	 * @return bool
	 */
323
	public function mark_job_completed( $timestamp, $action, $instance ) {
324
		$job_post_id = $this->job_exists( $timestamp, $action, $instance, true );
325

326
		if ( ! $job_post_id ) {
327
328
329
			return false;
		}

330
		return $this->mark_job_post_completed( $job_post_id );
331
332
333
334
335
336
337
	}

	/**
	 * Set a job post to the "completed" status
	 *
	 * `wp_trash_post()` calls `wp_insert_post()`, which can't be used before `init` due to capabilities checks
	 */
Erick Hitter's avatar
Erick Hitter committed
338
	private function mark_job_post_completed( $job_post_id ) {
339
340
		// If called before `init`, we need to modify directly because post types aren't registered earlier
		if ( did_action( 'init' ) ) {
Erick Hitter's avatar
Erick Hitter committed
341
			wp_trash_post( $job_post_id );
342
343
344
		} else {
			global $wpdb;

Erick Hitter's avatar
Erick Hitter committed
345
346
347
			$wpdb->update( 'posts', array( 'post_status' => self::POST_STATUS_COMPLETED, ), array( 'ID' => $job_post_id, ) );
			wp_add_trashed_suffix_to_post_name_for_post( $job_post_id );
			$this->posts_to_clean[] = $job_post_id;
348
		}
349

350
351
352
		// Delete internal cache
		wp_cache_delete( self::CACHE_KEY );

353
354
		return true;
	}
355
356
357
358
359
360
361
362
363
364
365
366
367
368

	/**
	 * Determine if current request is a call to `wp_unschedule_event()`
	 */
	private function is_unscheduling() {
		return false !== array_search( 'wp_unschedule_event', wp_debug_backtrace_summary( __CLASS__, null, false ) );
	}

	/**
	 * Identify jobs unscheduled using `wp_unschedule_event()` by comparing current value with previous
	 */
	private function find_unscheduled_jobs( $new, $old ) {
		$differences = array();

369
		$old = collapse_events_array( $old );
370

371
372
373
374
375
376
377
378
379
380
381
		foreach ( $old as $event ) {
			$timestamp = $event['timestamp'];
			$action    = $event['action'];
			$instance  = $event['instance'];

			if ( ! isset( $new[ $timestamp ][ $action ][ $instance ] ) ) {
				$differences[] = array(
					'timestamp' => $timestamp,
					'action'    => $action,
					'instance'  => $instance,
				);
382
383
384
385
386
			}
		}

		return $differences;
	}
387
388
389
390
391
392
393
394
395
396
397
398
399
400

	/**
	 * Generate a standardized post name from an event's arguments
	 */
	private function event_name( $timestamp, $action, $instance ) {
		return sprintf( '%s-%s-%s', $timestamp, md5( $action ), $instance );
	}

	/**
	 * Generate a standardized, human-readable post title from an event's arguments
	 */
	private function event_title( $timestamp, $action, $instance ) {
		return sprintf( '%s | %s | %s', $timestamp, $action, $instance );
	}
401
402
403
}

Cron_Options_CPT::instance();