class-events-store.php 19.8 KB
Newer Older
Erick Hitter's avatar
Erick Hitter committed
1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace Automattic\WP\Cron_Control;

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

	/**
	 * Class properties
	 */
13
	const TABLE_SUFFIX = 'a8c_cron_control_jobs';
14

15
16
17
	const DB_VERSION        = 1;
	const DB_VERSION_OPTION = 'a8c_cron_control_db_version';
	const TABLE_CREATE_LOCK = 'a8c_cron_control_creating_table';
18

19
20
21
	const STATUS_PENDING   = 'pending';
	const STATUS_RUNNING   = 'running';
	const STATUS_COMPLETED = 'complete';
22
23
24
25
26
	const ALLOWED_STATUSES = array(
		self::STATUS_PENDING,
		self::STATUS_RUNNING,
		self::STATUS_COMPLETED,
	);
27
28
29
30

	const CACHE_KEY = 'a8c_cron_ctrl_option';

	private $job_creation_suspended = false;
Erick Hitter's avatar
Erick Hitter committed
31
32
33
34

	/**
	 * Register hooks
	 */
35
	protected function class_init() {
36
37
		// Create tables during installation
		add_action( 'wp_install', array( $this, 'create_table_during_install' ) );
Erick Hitter's avatar
Erick Hitter committed
38
		add_action( 'wpmu_new_blog', array( $this, 'create_tables_during_multisite_install' ) );
39

40
41
42
		// Remove table when a multisite subsite is deleted
		add_filter( 'wpmu_drop_tables', array( $this, 'remove_multisite_table' ) );

43
		// Enable plugin when conditions support it, otherwise limit errors as much as possible
44
		if ( self::is_installed() ) {
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
			// Option interception
			add_filter( 'pre_option_cron', array( $this, 'get_option' ) );
			add_filter( 'pre_update_option_cron', array( $this, 'update_option' ), 10, 2 );

			// Disallow duplicates
			add_filter( 'schedule_event', array( $this, 'block_creation_if_job_exists' ) );
		} else {
			// Can't create events since there's no table to hold them
			$this->suspend_event_creation();

			// Prime plugin's options when the options table exists
			if ( ! defined( 'WP_INSTALLING' ) || ! WP_INSTALLING ) {
				$this->prime_options();
			}

			// Don't schedule events that won't be run
			add_filter( 'schedule_event', '__return_false' );
62
63

			// In limited circumstances, try creating the table
64
			add_action( 'shutdown', array( $this, 'maybe_create_table_on_shutdown' ) );
65
		}
66
67
	}

68
69
70
71
72
73
74
75
76
77
78
79
80
	/**
	 * Check if events store is ready
	 *
	 * Plugin breaks spectacularly if events store isn't available
	 *
	 * @return bool
	 */
	public static function is_installed() {
		$db_version = (int) get_option( self::DB_VERSION_OPTION );

		return version_compare( $db_version, 0, '>' );
	}

81
82
83
	/**
	 * Build appropriate table name for this install
	 */
84
	public function get_table_name() {
85
86
		global $wpdb;

87
		return $wpdb->prefix . self::TABLE_SUFFIX;
88
89
90
	}

	/**
91
92
93
94
95
96
97
98
	 * Set initial options that control plugin's behaviour
	 */
	protected function prime_options() {
		// Prime DB option
		add_option( self::DB_VERSION_OPTION, 0, null, false );
	}

	/**
Erick Hitter's avatar
Erick Hitter committed
99
	 * Create table during initial install
100
	 */
101
102
103
104
105
106
107
108
	public function create_table_during_install() {
		if ( 'wp_install' !== current_action() ) {
			return;
		}

		$this->_prepare_table();
	}

Erick Hitter's avatar
Erick Hitter committed
109
110
111
112
113
114
115
	/**
	 * Create table when new subsite is added to a multisite
	 */
	public function create_tables_during_multisite_install( $bid ) {
		switch_to_blog( $bid );

		if ( ! self::is_installed() ) {
116
			$this->_prepare_table();
Erick Hitter's avatar
Erick Hitter committed
117
118
119
120
121
		}

		restore_current_blog();
	}

122
123
124
125
126
	/**
	 * For certain requests, create the table on shutdown
	 * Does not include front-end requests
	 */
	public function maybe_create_table_on_shutdown() {
127
		if ( ! is_admin() && ! is_rest_endpoint_request( REST_API::ENDPOINT_LIST ) ) {
128
129
130
131
132
133
			return;
		}

		$this->prepare_table();
	}

134
135
136
	/**
	 * Create table in non-setup contexts, with some protections
	 */
137
	public function prepare_table() {
138
		// Table installed
139
		if ( self::is_installed() ) {
140
141
142
			return;
		}

143
		// Nothing to do
144
145
		$current_version = (int) get_option( self::DB_VERSION_OPTION );
		if ( version_compare( $current_version, self::DB_VERSION, '>=' ) ) {
146
147
148
			return;
		}

149
		// Limit chance of race conditions when creating table
Erick Hitter's avatar
Erick Hitter committed
150
151
		$create_lock_set = wp_cache_add( self::TABLE_CREATE_LOCK, 1, null, 1 * \MINUTE_IN_SECONDS );
		if ( false === $create_lock_set ) {
152
153
154
			return;
		}

155
156
157
158
159
160
161
		$this->_prepare_table();
	}

	/**
	 * Create the plugin's DB table when necessary
	 */
	protected function _prepare_table() {
162
163
164
165
166
167
168
		// Use Core's method of creating/updating tables
		if ( ! function_exists( 'dbDelta' ) ) {
			require_once ABSPATH . '/wp-admin/includes/upgrade.php';
		}

		global $wpdb;

Erick Hitter's avatar
Erick Hitter committed
169
170
		$table_name = $this->get_table_name();

171
		// Define schema and create the table
Erick Hitter's avatar
Erick Hitter committed
172
		$schema = "CREATE TABLE `{$table_name}` (
173
			`ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
174
175
176

			`timestamp` bigint(20) unsigned NOT NULL,
			`action` varchar(255) NOT NULL,
177
			`action_hashed` varchar(32) NOT NULL,
178
179
180
181
			`instance` varchar(32) NOT NULL,

			`args` longtext NOT NULL,
			`schedule` varchar(255) DEFAULT NULL,
182
183
			`interval` int unsigned DEFAULT 0,
			`status` varchar(32) NOT NULL DEFAULT 'pending',
184
185
186
187

			`created` datetime NOT NULL,
			`last_modified` datetime NOT NULL,

Erick Hitter's avatar
Erick Hitter committed
188
			PRIMARY KEY (`ID`),
Erick Hitter's avatar
Erick Hitter committed
189
190
			UNIQUE KEY `ts_action_instance_status` (`timestamp`, `action` (191), `instance`, `status`),
			KEY `status` (`status`)
191
192
193
194
195
		) ENGINE=InnoDB;\n";

		dbDelta( $schema, true );

		// Confirm that the table was created, and set the option to prevent further updates
Erick Hitter's avatar
Erick Hitter committed
196
		$table_count = count( $wpdb->get_col( "SHOW TABLES LIKE '{$table_name}'" ) );
197
198

		if ( 1 === $table_count ) {
199
			update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
200
		}
201
202
203

		// Clear caches now that table exists
		$this->flush_internal_caches();
204
	}
205

206
207
208
209
210
211
212
213
214
215
216
	/**
	 * Prepare table on demand via CLI
	 */
	public function cli_create_tables() {
		if ( ! defined( 'WP_CLI' ) || ! \WP_CLI ) {
			return;
		}

		$this->_prepare_table();
	}

217
218
219
220
221
222
223
224
225
226
227
228
229
230
	/**
	 * When deleting a subsite from a multisite instance, include the plugin's table
	 *
	 * Core only drops its tables
	 *
	 * @param  array $tables_to_drop Array of prefixed table names to drop
	 * @return array
	 */
	public function remove_multisite_table( $tables_to_drop ) {
		$tables_to_drop[] = $this->get_table_name();

		return $tables_to_drop;
	}

231
232
233
234
235
	/**
	 * PLUGIN FUNCTIONALITY
	 */

	/**
236
	 * Override cron option requests with data from custom table
237
238
	 */
	public function get_option() {
239
240
		// Use cached value when available
		$cached_option = wp_cache_get( self::CACHE_KEY, null, true );
241

242
243
		if ( false !== $cached_option ) {
			return $cached_option;
244
245
246
247
248
249
250
251
		}

		// Start building a new cron option
		$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
252
253
		$page     = 1;
		$quantity = 100;
254
255

		do {
256
			$jobs = $this->get_jobs( array(
257
				'status'   => self::STATUS_PENDING,
258
				'quantity' => $quantity,
259
				'page'     => $page++,
260
261
262
			) );

			// Nothing more to add
263
			if ( empty( $jobs ) ) {
264
265
266
267
				break;
			}

			// Loop through results and built output Core expects
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
			foreach ( $jobs as $job ) {
				// Alias event timestamp
				$timestamp = $job->timestamp;

				// If timestamp is invalid, event is removed to let its source fix it
				if ( $timestamp <= 0 ) {
					$this->mark_job_record_completed( $job->ID );
					continue;
				}

				// Basic arguments to add a job to the array format Core expects
				$action   = $job->action;
				$instance = $job->instance;

				// Populate remaining job data
				$cron_array[ $timestamp ][ $action ][ $instance ] = array(
					'schedule' => $job->schedule,
					'args'     => $job->args,
					'interval' => 0,
				);

				if ( isset( $job->interval ) ) {
					$cron_array[ $timestamp ][ $action ][ $instance ]['interval'] = $job->interval;
291
				}
292
			}
293
		} while( count( $jobs ) >= $quantity );
294
295
296
297
298

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

Erick Hitter's avatar
Erick Hitter committed
299
		// Cache the results
300
301
302
303
304
305
306
307
308
309
310
		wp_cache_set( self::CACHE_KEY, $cron_array, null, 1 * \HOUR_IN_SECONDS );

		return $cron_array;
	}

	/**
	 * Handle requests to update the cron option
	 *
	 * By returning $old_value, `cron` option won't be updated
	 */
	public function update_option( $new_value, $old_value ) {
311
312
313
		// Find changes to record
		$new_events     = $this->find_cron_array_differences( $new_value, $old_value );
		$deleted_events = $this->find_cron_array_differences( $old_value, $new_value );
314

315
316
		// Add/update new events
		foreach ( $new_events as $new_event ) {
317
			$job_id = $this->get_job_id( $new_event['timestamp'], $new_event['action'], $new_event['instance'] );
318

319
320
321
			if ( 0 === $job_id ) {
				$job_id = null;
			}
322

323
			$this->create_or_update_job( $new_event['timestamp'], $new_event['action'], $new_event['args'], $job_id, false );
324
325
		}

326
327
		// Mark deleted entries for removal
		foreach ( $deleted_events as $deleted_event ) {
328
			$this->mark_job_completed( $deleted_event['timestamp'], $deleted_event['action'], $deleted_event['instance'], false );
329
		}
330

331
332
		$this->flush_internal_caches();

333
		return $old_value;
334
335
	}

Erick Hitter's avatar
Erick Hitter committed
336
337
338
339
	/**
	 * When an entry exists, don't try to create it again
	 */
	public function block_creation_if_job_exists( $job ) {
340
341
342
343
344
		// Job already disallowed, carry on
		if ( ! is_object( $job ) ) {
			return $job;
		}

Erick Hitter's avatar
Erick Hitter committed
345
		$instance = md5( maybe_serialize( $job->args ) );
346
		if ( 0 !== $this->get_job_id( $job->timestamp, $job->hook, $instance ) ) {
Erick Hitter's avatar
Erick Hitter committed
347
348
349
350
351
352
			return false;
		}

		return $job;
	}

353
354
355
356
357
	/**
	 * PLUGIN UTILITY METHODS
	 */

	/**
Erick Hitter's avatar
Erick Hitter committed
358
359
360
	 * Retrieve jobs given a set of parameters
	 *
	 * @param array $args
361
	 * @return array
362
	 */
363
	public function get_jobs( $args ) {
364
365
366
367
368
369
370
371
372
373
374
375
376
		global $wpdb;

		if ( ! isset( $args['quantity'] ) || ! is_numeric( $args['quantity'] ) ) {
			$args['quantity'] = 100;
		}

		if ( isset( $args['page'] ) ) {
			$page  = max( 0, $args['page'] - 1 );
			$offset = $page * $args['quantity'];
		} else {
			$offset = 0;
		}

377
378
379
380
381
382
383
384
385
		// Avoid sorting whenever possible, otherwise filesort is used
		// Generally only necessary in CLI commands for pagination, as full list of events is usually required
		if ( isset( $args['force_sort'] ) && true === $args['force_sort'] ) {
			$query = $wpdb->prepare( "SELECT * FROM {$this->get_table_name()} WHERE status = %s ORDER BY timestamp ASC LIMIT %d,%d;", $args['status'], $offset, $args['quantity'] );
		} else {
			$query = $wpdb->prepare( "SELECT * FROM {$this->get_table_name()} WHERE status = %s LIMIT %d,%d;", $args['status'], $offset, $args['quantity'] );
		}

		$jobs = $wpdb->get_results( $query, 'OBJECT' );
386
387
388
389

		if ( is_array( $jobs ) ) {
			$jobs = array_map( array( $this, 'format_job' ), $jobs );
		} else {
390
			$jobs = array();
391
392
393
		}

		return $jobs;
394
395
	}

396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
	/**
	 * Retrieve a single event by its ID
	 *
	 * @param  int $jid Job ID
	 * @return object|false
	 */
	public function get_job_by_id( $jid ) {
		global $wpdb;

		// Validate ID
		$jid = absint( $jid );
		if ( ! $jid ) {
			return false;
		}

		$job = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->get_table_name()} WHERE ID = %d AND status = %s LIMIT 1", $jid, self::STATUS_PENDING ) );

		if ( is_object( $job ) && ! is_wp_error( $job ) ) {
			$job = $this->format_job( $job );
		} else {
			$job = false;
		}

		return $job;
	}

422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
	/**
	 * Retrieve a single event by a combination of its timestamp, instance identifier, and either action or the action's hashed representation
	 *
	 * @param  array $attrs Array of event attributes to query by
	 * @return object|false
	 */
	public function get_job_by_attributes( $attrs ) {
		global $wpdb;

		// Validate basic inputs
		if ( ! is_array( $attrs ) || empty( $attrs ) ) {
			return false;
		}

		// Validate requested status
		$allowed_status = self::ALLOWED_STATUSES;
		$allowed_status[] = 'any';

		if ( ! isset( $attrs['status'] ) || ! in_array( $attrs['status'], $allowed_status, true ) ) {
			$attrs['status'] = self::STATUS_PENDING;
		}

		// Need a timestamp, an instance, and either an action or its hashed representation
		if ( ! isset( $attrs['timestamp'] ) || ! isset( $attrs['instance'] ) ) {
			return false;
		} elseif ( ! isset( $attrs['action'] ) && ! isset( $attrs['action_hashed'] ) ) {
			return false;
		}

		// Build query
		if ( isset( $attrs['action'] ) ) {
			$action_column = 'action';
			$action_value  = $attrs['action'];
		} else {
			$action_column = 'action_hashed';
			$action_value  = $attrs['action_hashed'];
		}

		// Do not sort, otherwise index isn't used
		$query = $wpdb->prepare( "SELECT * FROM {$this->get_table_name()} WHERE timestamp = %d AND {$action_column} = %s AND instance = %s", $attrs['timestamp'], $action_value, $attrs['instance'] );

		// Final query preparations
		if ( 'any' !== $attrs['status'] ) {
			$query .= $wpdb->prepare( ' AND status = %s', $attrs['status'] );
		}

		$query .= ' LIMIT 1';

		// Query and format results
		$job = $wpdb->get_row( $query );

		if ( is_object( $job ) && ! is_wp_error( $job ) ) {
			$job = $this->format_job( $job );
		} else {
			$job = false;
		}

		return $job;
	}

482
483
484
	/**
	 * Get ID for given event details
	 *
485
486
	 * Used in situations where performance matters, which is why it exists despite duplicating `get_job_by_attributes()`
	 * Queries outside of this class should use `get_job_by_attributes()`
487
488
489
490
491
492
493
494
495
496
497
498
499
500
	 *
	 * @param  int    $timestamp    Unix timestamp event executes at
	 * @param  string $action       Name of action used when the event is registered (unhashed)
	 * @param  string $instance     md5 hash of the event's arguments array, which Core uses to index the `cron` option
	 * @return int
	 */
	private function get_job_id( $timestamp, $action, $instance ) {
		global $wpdb;

		$job = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$this->get_table_name()} WHERE timestamp = %d AND action = %s AND instance = %s AND status = %s LIMIT 1;", $timestamp, $action, $instance, self::STATUS_PENDING ) );

		return empty( $job ) ? 0 : (int) array_shift( $job );
	}

501
502
	/**
	 * Standardize formatting and expand serialized data
503
504
505
	 *
	 * @param  object $job Job row from DB, in object form
	 * @return object
506
	 */
507
508
509
	private function format_job( $job ) {
		if ( ! is_object( $job ) || is_wp_error( $job ) ) {
			return $job;
510
511
		}

512
513
514
515
		$job->ID        = (int) $job->ID;
		$job->timestamp = (int) $job->timestamp;
		$job->interval  = (int) $job->interval;
		$job->args      = maybe_unserialize( $job->args );
516

517
518
519
520
		if ( empty( $job->schedule ) ) {
			$job->schedule = false;
		}

521
		return $job;
522
523
524
	}

	/**
525
	 * Create or update entry for a given job
526
527
528
529
530
531
	 *
	 * @param int    $timestamp    Unix timestamp event executes at
	 * @param string $action       Hook event fires
	 * @param array  $args         Array of event's schedule, arguments, and interval
	 * @param bool   $update_id    ID of existing entry to update, rather than creating a new entry
	 * @param bool   $flush_cache  Whether or not to flush internal caches after creating/updating the event
532
	 */
533
	public function create_or_update_job( $timestamp, $action, $args, $update_id = null, $flush_cache = true ) {
534
535
536
537
538
539
540
541
542
543
		// Don't create new jobs when manipulating jobs via the plugin's CLI commands
		if ( $this->job_creation_suspended ) {
			return;
		}

		global $wpdb;

		$job_post = array(
			'timestamp'     => $timestamp,
			'action'        => $action,
544
			'action_hashed' => md5( $action ),
545
			'instance'      => md5( maybe_serialize( $args['args'] ) ),
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
			'args'          => maybe_serialize( $args['args'] ),
			'last_modified' => current_time( 'mysql', true ),
		);

		if ( isset( $args['schedule'] ) && ! empty( $args['schedule'] ) ) {
			$job_post['schedule'] = $args['schedule'];
		}

		if ( isset( $args['interval'] ) && ! empty( $args['interval'] ) && is_numeric( $args['interval'] ) ) {
			$job_post['interval'] = (int) $args['interval'];
		}

		// Create the post, or update an existing entry to run again in the future
		if ( is_int( $update_id ) && $update_id > 0 ) {
			$wpdb->update( $this->get_table_name(), $job_post, array( 'ID' => $update_id, ) );
		} else {
Erick Hitter's avatar
Erick Hitter committed
562
			$job_post['created'] = current_time( 'mysql', true );
563
564
565
566

			$wpdb->insert( $this->get_table_name(), $job_post );
		}

567
568
569
570
571
		// Delete internal cache
		// Should only be skipped during bulk operations
		if ( $flush_cache ) {
			$this->flush_internal_caches();
		}
572
573
574
	}

	/**
575
	 * Mark an event's entry as completed
576
577
578
	 *
	 * Completed entries will be cleaned up by an internal job
	 *
579
580
581
582
	 * @param int    $timestamp    Unix timestamp event executes at
	 * @param string $action       Name of action used when the event is registered (unhashed)
	 * @param string $instance     md5 hash of the event's arguments array, which Core uses to index the `cron` option
	 * @param bool   $flush_cache  Whether or not to flush internal caches after creating/updating the event
583
584
	 * @return bool
	 */
585
	public function mark_job_completed( $timestamp, $action, $instance, $flush_cache = true ) {
586
		$job_id = $this->get_job_id( $timestamp, $action, $instance );
587
588
589
590
591

		if ( ! $job_id ) {
			return false;
		}

592
		return $this->mark_job_record_completed( $job_id, $flush_cache );
593
594
595
596
	}

	/**
	 * Set a job post to the "completed" status
597
598
599
600
	 *
	 * @param int $job_id        ID of job's record
	 * @param bool $flush_cache  Whether or not to flush internal caches after creating/updating the event
	 * @return bool
601
	 */
Erick Hitter's avatar
Erick Hitter committed
602
	public function mark_job_record_completed( $job_id, $flush_cache = true ) {
603
604
		global $wpdb;

605
606
607
608
609
610
611
612
		/**
		 * Constraint is broken to accommodate the following situation:
		 * 1. Event with specific timestamp is scheduled.
		 * 2. Event is unscheduled.
		 * 3. Event is rescheduled.
		 * 4. Event runs, or is unscheduled, but unique constraint prevents query from succeeding.
		 * 5. Event retains `pending` status and runs again. Repeat steps 4 and 5 until `a8c_cron_control_purge_completed_events` runs and removes the entry from step 2.
		 */
613
614
		$updates = array(
			'status'   => self::STATUS_COMPLETED,
615
			'instance' => mt_rand( 1000000, 999999999 ), // Breaks unique constraint, and can be recreated from entry's remaining data
616
617
618
		);

		$success = $wpdb->update( $this->get_table_name(), $updates, array( 'ID' => $job_id, ) );
619
620

		// Delete internal cache
621
		// Should only be skipped during bulk operations
622
		if ( $flush_cache ) {
Erick Hitter's avatar
Erick Hitter committed
623
			$this->flush_internal_caches();
624
625
		}

Erick Hitter's avatar
Erick Hitter committed
626
		return (bool) $success;
627
628
	}

629
	/**
630
	 * Compare two arrays and return collapsed representation of the items present in one but not the other
631
	 *
632
633
	 * @param array $changed   Array to identify additional items from
	 * @param array $reference Array to compare against
634
	 *
635
	 * @return array
636
	 */
637
	private function find_cron_array_differences( $changed, $reference ) {
638
639
		$differences = array();

640
		$changed = collapse_events_array( $changed );
641

642
		foreach ( $changed as $event ) {
643
			$event = (object) $event;
644

645
			if ( ! isset( $reference[ $event->timestamp ][ $event->action ][ $event->instance ] ) ) {
646
				$differences[] = array(
647
648
649
650
					'timestamp' => $event->timestamp,
					'action'    => $event->action,
					'instance'  => $event->instance,
					'args'      => $event->args,
651
652
653
654
655
656
657
				);
			}
		}

		return $differences;
	}

Erick Hitter's avatar
Erick Hitter committed
658
659
660
661
662
663
664
	/**
	 * Delete the cached representation of the cron option
	 */
	public function flush_internal_caches() {
		return wp_cache_delete( self::CACHE_KEY );
	}

665
	/**
666
	 * Prevent event store from creating new entries
667
668
669
670
671
672
673
674
	 *
	 * Should be used sparingly, and followed by a call to resume_event_creation(), during bulk operations
	 */
	public function suspend_event_creation() {
		$this->job_creation_suspended = true;
	}

	/**
675
	 * Stop discarding events, once again storing them in the table
676
677
678
679
680
681
682
683
	 */
	public function resume_event_creation() {
		$this->job_creation_suspended = false;
	}

	/**
	 * Remove entries for non-recurring events that have been run
	 */
684
	public function purge_completed_events( $count_first = true ) {
685
686
		global $wpdb;

687
688
		// Skip count if already performed
		if ( $count_first ) {
689
690
691
692
693
			if ( property_exists( $wpdb, 'srtm' ) ) {
				$srtm = $wpdb->srtm;
				$wpdb->srtm = true;
			}

694
			$count = $this->count_events_by_status( self::STATUS_COMPLETED );
695
696
697
698

			if ( isset( $srtm ) ) {
				$wpdb->srtm = $srtm;
			}
699
700
701
702
703
704
705
		} else {
			$count = 1;
		}

		if ( $count > 0 ) {
			$wpdb->delete( $this->get_table_name(), array( 'status' => self::STATUS_COMPLETED, ) );
		}
706
	}
Erick Hitter's avatar
Erick Hitter committed
707
708
709
710
711
712
713
714
715
716

	/**
	 * Count number of events with a given status
	 *
	 * @param string $status
	 * @return int|false
	 */
	public function count_events_by_status( $status ) {
		global $wpdb;

717
		if ( ! in_array( $status, self::ALLOWED_STATUSES, true ) ) {
Erick Hitter's avatar
Erick Hitter committed
718
719
720
			return false;
		}

721
		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(ID) FROM {$this->get_table_name()} WHERE status = %s", $status ) );
Erick Hitter's avatar
Erick Hitter committed
722
	}
Erick Hitter's avatar
Erick Hitter committed
723
724
725
}

Events_Store::instance();