PluginProbe
WANotifier for Forms and Actions / 3.1.1
WANotifier for Forms and Actions v3.1.1
3.1.1 3.1.0 3.0.4 2.7.10 2.7.11 2.7.12 2.7.13 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 3.0.0 3.0.1 3.0.2 3.0.3 trunk 0.1.0 0.1.1 1.0.0 1.0.1 1.0.2 All 67 releases
notifier / libraries / action-scheduler / classes / data-stores / ActionScheduler_DBStore.php

ActionScheduler_DBStore.php in WANotifier for Forms and Actions 3.1.1, at libraries/action-scheduler/classes/data-stores/ActionScheduler_DBStore.php

1,434 lines 43.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Class ActionScheduler_DBStore
5 *
6 * Action data table data store.
7 *
8 * @since 3.0.0
9 */
10 class ActionScheduler_DBStore extends ActionScheduler_Store {
11
12 /**
13 * WordPress object cache group for resolved group IDs.
14 */
15 const GROUP_IDS_CACHE_GROUP = 'action_scheduler_groups';
16
17 /**
18 * Cache key used for the default (empty-slug) group, since wp_cache_* rejects empty string keys.
19 */
20 const GROUP_IDS_DEFAULT_CACHE_KEY = 'group:default';
21
22 /**
23 * Used to share information about the before_date property of claims internally.
24 *
25 * This is used in preference to passing the same information as a method param
26 * for backwards-compatibility reasons.
27 *
28 * @var DateTime|null
29 */
30 private $claim_before_date = null;
31
32 /**
33 * Maximum length of args.
34 *
35 * @var int
36 */
37 protected static $max_args_length = 8000;
38
39 /**
40 * Maximum length of index.
41 *
42 * @var int
43 */
44 protected static $max_index_length = 191;
45
46 /**
47 * List of claim filters.
48 *
49 * @var array
50 */
51 protected $claim_filters = array(
52 'group' => '',
53 'hooks' => '',
54 'exclude-groups' => '',
55 );
56
57 /**
58 * Initialize the data store
59 *
60 * @codeCoverageIgnore
61 */
62 public function init() {
63 $table_maker = new ActionScheduler_StoreSchema();
64 $table_maker->init();
65 $table_maker->register_tables();
66 }
67
68 /**
69 * Flush all store caches.
70 */
71 public function flush_caches() {
72 wp_cache_flush_group( self::GROUP_IDS_CACHE_GROUP );
73 }
74
75 /**
76 * Translate a group slug into a valid (non-empty) object cache key.
77 *
78 * @param string $slug Group slug.
79 * @return string
80 */
81 private function get_group_cache_key( string $slug ): string {
82 return '' !== $slug ? $slug : self::GROUP_IDS_DEFAULT_CACHE_KEY;
83 }
84
85 /**
86 * Save an action, checks if this is a unique action before actually saving.
87 *
88 * @param ActionScheduler_Action $action Action object.
89 * @param DateTime|null $scheduled_date Optional schedule date. Default null.
90 *
91 * @return int Action ID.
92 * @throws RuntimeException Throws exception when saving the action fails.
93 */
94 public function save_unique_action( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) {
95 return $this->save_action_to_db( $action, $scheduled_date, true );
96 }
97
98 /**
99 * Save an action. Can save duplicate action as well, prefer using `save_unique_action` instead.
100 *
101 * @param ActionScheduler_Action $action Action object.
102 * @param DateTime|null $scheduled_date Optional schedule date. Default null.
103 *
104 * @return int Action ID.
105 * @throws RuntimeException Throws exception when saving the action fails.
106 */
107 public function save_action( ActionScheduler_Action $action, ?DateTime $scheduled_date = null ) {
108 return $this->save_action_to_db( $action, $scheduled_date, false );
109 }
110
111 /**
112 * Save an action.
113 *
114 * @param ActionScheduler_Action $action Action object.
115 * @param ?DateTime $date Optional schedule date. Default null.
116 * @param bool $unique Whether the action should be unique.
117 *
118 * @return int Action ID.
119 * @throws \RuntimeException Throws exception when saving the action fails.
120 */
121 private function save_action_to_db( ActionScheduler_Action $action, ?DateTime $date = null, $unique = false ) {
122 global $wpdb;
123
124 try {
125 $this->validate_action( $action );
126
127 $data = array(
128 'hook' => $action->get_hook(),
129 'status' => ( $action->is_finished() ? self::STATUS_COMPLETE : self::STATUS_PENDING ),
130 'scheduled_date_gmt' => $this->get_scheduled_date_string( $action, $date ),
131 'scheduled_date_local' => $this->get_scheduled_date_string_local( $action, $date ),
132 'schedule' => serialize( $action->get_schedule() ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
133 'group_id' => current( $this->get_group_ids( $action->get_group() ) ),
134 'priority' => $action->get_priority(),
135 );
136
137 $args = wp_json_encode( $action->get_args() );
138 if ( strlen( $args ) <= static::$max_index_length ) {
139 $data['args'] = $args;
140 } else {
141 $data['args'] = $this->hash_args( $args );
142 $data['extended_args'] = $args;
143 }
144
145 $insert_sql = $this->build_insert_sql( $data, $unique );
146
147 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $insert_sql should be already prepared.
148 $wpdb->query( $insert_sql );
149 $action_id = $wpdb->insert_id;
150
151 if ( is_wp_error( $action_id ) ) {
152 throw new \RuntimeException( $action_id->get_error_message() );
153 } elseif ( empty( $action_id ) ) {
154 if ( $unique ) {
155 return 0;
156 }
157 throw new \RuntimeException( $wpdb->last_error ? $wpdb->last_error : __( 'Database error.', 'action-scheduler' ) );
158 }
159
160 do_action( 'action_scheduler_stored_action', $action_id );
161
162 return $action_id;
163 } catch ( \Exception $e ) {
164 /* translators: %s: error message */
165 throw new \RuntimeException( sprintf( __( 'Error saving action: %s', 'action-scheduler' ), $e->getMessage() ), 0 );
166 }
167 }
168
169 /**
170 * Helper function to build insert query.
171 *
172 * @param array $data Row data for action.
173 * @param bool $unique Whether the action should be unique.
174 *
175 * @return string Insert query.
176 */
177 private function build_insert_sql( array $data, $unique ) {
178 global $wpdb;
179
180 $columns = array_keys( $data );
181 $values = array_values( $data );
182 $placeholders = array_map( array( $this, 'get_placeholder_for_column' ), $columns );
183
184 $table_name = ! empty( $wpdb->actionscheduler_actions ) ? $wpdb->actionscheduler_actions : $wpdb->prefix . 'actionscheduler_actions';
185
186 $column_sql = '`' . implode( '`, `', $columns ) . '`';
187 $placeholder_sql = implode( ', ', $placeholders );
188 $where_clause = $this->build_where_clause_for_insert( $data, $table_name, $unique );
189
190 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $column_sql and $where_clause are already prepared. $placeholder_sql is hardcoded.
191 $insert_query = $wpdb->prepare(
192 "
193 INSERT INTO $table_name ( $column_sql )
194 SELECT $placeholder_sql FROM DUAL
195 WHERE ( $where_clause ) IS NULL",
196 $values
197 );
198 // phpcs:enable
199
200 return $insert_query;
201 }
202
203 /**
204 * Helper method to build where clause for action insert statement.
205 *
206 * @param array $data Row data for action.
207 * @param string $table_name Action table name.
208 * @param bool $unique Where action should be unique.
209 *
210 * @return string Where clause to be used with insert.
211 */
212 private function build_where_clause_for_insert( $data, $table_name, $unique ) {
213 global $wpdb;
214
215 if ( ! $unique ) {
216 return 'SELECT NULL FROM DUAL';
217 }
218
219 $pending_statuses = array(
220 ActionScheduler_Store::STATUS_PENDING,
221 ActionScheduler_Store::STATUS_RUNNING,
222 );
223 $pending_status_placeholders = implode( ', ', array_fill( 0, count( $pending_statuses ), '%s' ) );
224
225 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $pending_status_placeholders is hardcoded.
226 $where_clause = $wpdb->prepare(
227 "
228 SELECT action_id FROM $table_name
229 WHERE status IN ( $pending_status_placeholders )
230 AND hook = %s
231 AND `group_id` = %d
232 AND args = %s
233 ",
234 array_merge(
235 $pending_statuses,
236 array(
237 $data['hook'],
238 $data['group_id'],
239 $data['args'],
240 )
241 )
242 );
243 // phpcs:enable
244
245 return "$where_clause" . ' LIMIT 1';
246 }
247
248 /**
249 * Helper method to get $wpdb->prepare placeholder for a given column name.
250 *
251 * @param string $column_name Name of column in actions table.
252 *
253 * @return string Placeholder to use for given column.
254 */
255 private function get_placeholder_for_column( $column_name ) {
256 $string_columns = array(
257 'hook',
258 'status',
259 'scheduled_date_gmt',
260 'scheduled_date_local',
261 'args',
262 'schedule',
263 'last_attempt_gmt',
264 'last_attempt_local',
265 'extended_args',
266 );
267
268 return in_array( $column_name, $string_columns, true ) ? '%s' : '%d';
269 }
270
271 /**
272 * Generate a hash from json_encoded $args using MD5 as this isn't for security.
273 *
274 * @param string $args JSON encoded action args.
275 * @return string
276 */
277 protected function hash_args( $args ) {
278 return md5( $args );
279 }
280
281 /**
282 * Get action args query param value from action args.
283 *
284 * @param array $args Action args.
285 * @return string
286 */
287 protected function get_args_for_query( $args ) {
288 $encoded = wp_json_encode( $args );
289 if ( strlen( $encoded ) <= static::$max_index_length ) {
290 return $encoded;
291 }
292 return $this->hash_args( $encoded );
293 }
294
295 /**
296 * Get a group's ID based on its name/slug.
297 *
298 * @param string|array $slugs The string name of a group, or names for several groups.
299 * @param bool $create_if_not_exists Whether to create the group if it does not already exist. Default, true - create the group.
300 *
301 * @return int[] The group IDs, if they exist or were successfully created. May be empty.
302 */
303 protected function get_group_ids( $slugs, $create_if_not_exists = true ) {
304 $slugs = (array) $slugs;
305
306 if ( empty( $slugs ) ) {
307 return array();
308 }
309
310 /**
311 * Global.
312 *
313 * @var \wpdb $wpdb
314 */
315 global $wpdb;
316
317 // For the primary path, resolve slugs using the WordPress cache first, then identify which slugs require a database lookup.
318 $group_ids = array();
319 $unresolved = array();
320 foreach ( $slugs as $slug ) {
321 $cached_group_id = wp_cache_get( $this->get_group_cache_key( (string) $slug ), self::GROUP_IDS_CACHE_GROUP );
322 if ( false !== $cached_group_id ) {
323 $group_ids[] = (int) $cached_group_id;
324 } else {
325 $unresolved[] = $slug;
326 }
327 }
328
329 // For the secondary or initialization path, bulk-resolve any remaining slugs from the database.
330 if ( ! empty( $unresolved ) ) {
331 $resolved = array();
332
333 // Bulk-fetch unresolved slugs from the database. It is acceptable not to use wp_cache_set_multiple to maintain code simplicity.
334 $placeholders = implode( ', ', array_fill( 0, count( $unresolved ), '%s' ) );
335 $rows = $wpdb->get_results(
336 $wpdb->prepare(
337 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
338 "SELECT group_id, slug FROM {$wpdb->actionscheduler_groups} WHERE slug IN ( $placeholders )",
339 ...$unresolved
340 )
341 );
342 foreach ( $rows as $row ) {
343 $resolved[] = $row->slug;
344 wp_cache_set( $this->get_group_cache_key( $row->slug ), $row->group_id, self::GROUP_IDS_CACHE_GROUP, 10 * MINUTE_IN_SECONDS );
345 }
346
347 // Automatically create unresolved slugs if requested.
348 if ( $create_if_not_exists ) {
349 foreach ( array_diff( $unresolved, $resolved ) as $slug ) {
350 $this->create_group( $slug );
351 }
352 }
353
354 // Ensure stable sorting by aligning the IDs, which are currently sourced from various locations, to match the order of the provided slugs.
355 $group_ids = array_values(
356 array_filter(
357 array_map(
358 function ( $slug ) {
359 return (int) wp_cache_get( $this->get_group_cache_key( (string) $slug ), self::GROUP_IDS_CACHE_GROUP );
360 },
361 $slugs
362 )
363 )
364 );
365 }
366
367 return $group_ids;
368 }
369
370 /**
371 * Create an action group.
372 *
373 * @param string $slug Group slug.
374 *
375 * @return int Group ID.
376 */
377 protected function create_group( $slug ) {
378 /**
379 * Global.
380 *
381 * @var \wpdb $wpdb
382 */
383 global $wpdb;
384
385 $wpdb->insert( $wpdb->actionscheduler_groups, array( 'slug' => $slug ) );
386
387 $group_id = (int) $wpdb->insert_id;
388 if ( $group_id ) {
389 wp_cache_set( $this->get_group_cache_key( $slug ), $group_id, self::GROUP_IDS_CACHE_GROUP, 10 * MINUTE_IN_SECONDS );
390 }
391
392 return $group_id;
393 }
394
395 /**
396 * Retrieve an action.
397 *
398 * @param int $action_id Action ID.
399 *
400 * @return ActionScheduler_Action
401 */
402 public function fetch_action( $action_id ) {
403 /**
404 * Global.
405 *
406 * @var \wpdb $wpdb
407 */
408 global $wpdb;
409
410 $data = $wpdb->get_row(
411 $wpdb->prepare(
412 "SELECT a.*, g.slug AS `group` FROM {$wpdb->actionscheduler_actions} a LEFT JOIN {$wpdb->actionscheduler_groups} g ON a.group_id=g.group_id WHERE a.action_id=%d",
413 $action_id
414 )
415 );
416
417 if ( empty( $data ) ) {
418 return $this->get_null_action();
419 }
420
421 if ( ! empty( $data->extended_args ) ) {
422 $data->args = $data->extended_args;
423 unset( $data->extended_args );
424 }
425
426 // Convert NULL dates to zero dates.
427 $date_fields = array(
428 'scheduled_date_gmt',
429 'scheduled_date_local',
430 'last_attempt_gmt',
431 'last_attempt_gmt',
432 );
433 foreach ( $date_fields as $date_field ) {
434 if ( is_null( $data->$date_field ) ) {
435 $data->$date_field = ActionScheduler_StoreSchema::DEFAULT_DATE;
436 }
437 }
438
439 try {
440 $action = $this->make_action_from_db_record( $data );
441 } catch ( ActionScheduler_InvalidActionException $exception ) {
442 do_action( 'action_scheduler_failed_fetch_action', $action_id, $exception );
443 return $this->get_null_action();
444 }
445
446 return $action;
447 }
448
449 /**
450 * Create a null action.
451 *
452 * @return ActionScheduler_NullAction
453 */
454 protected function get_null_action() {
455 return new ActionScheduler_NullAction();
456 }
457
458 /**
459 * Create an action from a database record.
460 *
461 * @param object $data Action database record.
462 *
463 * @return ActionScheduler_Action|ActionScheduler_CanceledAction|ActionScheduler_FinishedAction
464 */
465 protected function make_action_from_db_record( $data ) {
466 $args = json_decode( $data->args, true );
467 if ( ! is_array( $args ) && $data->status === self::STATUS_CANCELED ) {
468 // The handled corrupted action should appear in UI.
469 $args = array();
470 } else {
471 $this->validate_args( $args, $data->action_id );
472 }
473
474 $schedule = ActionScheduler_ScheduleDeserializer::unserialize( $data->schedule );
475 if ( false === $schedule && $data->status === self::STATUS_CANCELED ) {
476 // The handled corrupted action should appear in UI.
477 $schedule = new ActionScheduler_NullSchedule();
478 } else {
479 $this->validate_schedule( $schedule, $data->action_id );
480 }
481
482 $group = $data->group ? $data->group : '';
483
484 return ActionScheduler::factory()->get_stored_action( $data->status, $data->hook, $args, $schedule, $group, $data->priority );
485 }
486
487 /**
488 * Returns the SQL statement to query (or count) actions.
489 *
490 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
491 *
492 * @param array $query Filtering options.
493 * @param string $select_or_count Whether the SQL should select and return the IDs or just the row count.
494 *
495 * @return string SQL statement already properly escaped.
496 * @throws \InvalidArgumentException If the query is invalid.
497 * @throws \RuntimeException When "unknown partial args matching value".
498 */
499 protected function get_query_actions_sql( array $query, $select_or_count = 'select' ) {
500
501 if ( ! in_array( $select_or_count, array( 'select', 'count' ), true ) ) {
502 throw new InvalidArgumentException( __( 'Invalid value for select or count parameter. Cannot query actions.', 'action-scheduler' ) );
503 }
504
505 $query = wp_parse_args(
506 $query,
507 array(
508 'hook' => '',
509 'args' => null,
510 'partial_args_matching' => 'off', // can be 'like' or 'json'.
511 'date' => null,
512 'date_compare' => '<=',
513 'modified' => null,
514 'modified_compare' => '<=',
515 'group' => '',
516 'status' => '',
517 'claimed' => null,
518 'per_page' => 5,
519 'offset' => 0,
520 'orderby' => 'date',
521 'order' => 'ASC',
522 )
523 );
524
525 /**
526 * Global.
527 *
528 * @var \wpdb $wpdb
529 */
530 global $wpdb;
531
532 $db_server_info = is_callable( array( $wpdb, 'db_server_info' ) ) ? $wpdb->db_server_info() : $wpdb->db_version();
533 if ( false !== strpos( $db_server_info, 'MariaDB' ) ) {
534 $supports_json = version_compare(
535 PHP_VERSION_ID >= 80016 ? $wpdb->db_version() : preg_replace( '/[^0-9.].*/', '', str_replace( '5.5.5-', '', $db_server_info ) ),
536 '10.2',
537 '>='
538 );
539 } else {
540 $supports_json = version_compare( $wpdb->db_version(), '5.7', '>=' );
541 }
542
543 $sql = ( 'count' === $select_or_count ) ? 'SELECT count(a.action_id)' : 'SELECT a.action_id';
544 $sql .= " FROM {$wpdb->actionscheduler_actions} a";
545 $sql_params = array();
546
547 if ( ! empty( $query['group'] ) || 'group' === $query['orderby'] ) {
548 $sql .= " LEFT JOIN {$wpdb->actionscheduler_groups} g ON g.group_id=a.group_id";
549 }
550
551 $sql .= ' WHERE 1=1';
552
553 if ( ! empty( $query['group'] ) ) {
554 $sql .= ' AND g.slug=%s';
555 $sql_params[] = $query['group'];
556 }
557
558 if ( ! empty( $query['hook'] ) ) {
559 $sql .= ' AND a.hook=%s';
560 $sql_params[] = $query['hook'];
561 }
562
563 if ( ! is_null( $query['args'] ) ) {
564 switch ( $query['partial_args_matching'] ) {
565 case 'json':
566 if ( ! $supports_json ) {
567 throw new \RuntimeException( __( 'JSON partial matching not supported in your environment. Please check your MySQL/MariaDB version.', 'action-scheduler' ) );
568 }
569 $supported_types = array(
570 'integer' => '%d',
571 'boolean' => '%s',
572 'double' => '%f',
573 'string' => '%s',
574 );
575 foreach ( $query['args'] as $key => $value ) {
576 $value_type = gettype( $value );
577 if ( 'boolean' === $value_type ) {
578 $value = $value ? 'true' : 'false';
579 }
580 $placeholder = isset( $supported_types[ $value_type ] ) ? $supported_types[ $value_type ] : false;
581 if ( ! $placeholder ) {
582 throw new \RuntimeException(
583 sprintf(
584 /* translators: %s: provided value type */
585 __( 'The value type for the JSON partial matching is not supported. Must be either integer, boolean, double or string. %s type provided.', 'action-scheduler' ),
586 $value_type
587 )
588 );
589 }
590 $sql .= ' AND JSON_EXTRACT(a.args, %s)=' . $placeholder;
591 $sql_params[] = '$.' . $key;
592 $sql_params[] = $value;
593 }
594 break;
595 case 'like':
596 foreach ( $query['args'] as $key => $value ) {
597 $sql .= ' AND a.args LIKE %s';
598 $json_partial = $wpdb->esc_like( trim( wp_json_encode( array( $key => $value ) ), '{}' ) );
599 $sql_params[] = "%{$json_partial}%";
600 }
601 break;
602 case 'off':
603 $sql .= ' AND a.args=%s';
604 $sql_params[] = $this->get_args_for_query( $query['args'] );
605 break;
606 default:
607 throw new \RuntimeException( __( 'Unknown partial args matching value.', 'action-scheduler' ) );
608 }
609 }
610
611 if ( $query['status'] ) {
612 $statuses = (array) $query['status'];
613 $placeholders = array_fill( 0, count( $statuses ), '%s' );
614 $sql .= ' AND a.status IN (' . join( ', ', $placeholders ) . ')';
615 $sql_params = array_merge( $sql_params, array_values( $statuses ) );
616 }
617
618 if ( $query['date'] instanceof \DateTime ) {
619 $date = clone $query['date'];
620 $date->setTimezone( new \DateTimeZone( 'UTC' ) );
621 $date_string = $date->format( 'Y-m-d H:i:s' );
622 $comparator = $this->validate_sql_comparator( $query['date_compare'] );
623 $sql .= " AND a.scheduled_date_gmt $comparator %s";
624 $sql_params[] = $date_string;
625 }
626
627 if ( $query['modified'] instanceof \DateTime ) {
628 $modified = clone $query['modified'];
629 $modified->setTimezone( new \DateTimeZone( 'UTC' ) );
630 $date_string = $modified->format( 'Y-m-d H:i:s' );
631 $comparator = $this->validate_sql_comparator( $query['modified_compare'] );
632 $sql .= " AND a.last_attempt_gmt $comparator %s";
633 $sql_params[] = $date_string;
634 }
635
636 if ( true === $query['claimed'] ) {
637 $sql .= ' AND a.claim_id != 0';
638 } elseif ( false === $query['claimed'] ) {
639 $sql .= ' AND a.claim_id = 0';
640 } elseif ( ! is_null( $query['claimed'] ) ) {
641 $sql .= ' AND a.claim_id = %d';
642 $sql_params[] = $query['claimed'];
643 }
644
645 if ( ! empty( $query['search'] ) ) {
646 $sql .= ' AND (a.hook LIKE %s OR (a.extended_args IS NULL AND a.args LIKE %s) OR a.extended_args LIKE %s';
647 for ( $i = 0; $i < 3; $i++ ) {
648 $sql_params[] = sprintf( '%%%s%%', $query['search'] );
649 }
650
651 $search_id = (int) $query['search'];
652 if ( $search_id ) {
653 $sql .= ' OR a.action_id = %d OR a.claim_id = %d';
654 $sql_params[] = $search_id;
655 $sql_params[] = $search_id;
656 }
657
658 $sql .= ')';
659 }
660
661 if ( 'select' === $select_or_count ) {
662 if ( 'ASC' === strtoupper( $query['order'] ) ) {
663 $order = 'ASC';
664 } else {
665 $order = 'DESC';
666 }
667 switch ( $query['orderby'] ) {
668 case 'hook':
669 $sql .= " ORDER BY a.hook $order";
670 break;
671 case 'group':
672 $sql .= " ORDER BY g.slug $order";
673 break;
674 case 'modified':
675 $sql .= " ORDER BY a.last_attempt_gmt $order";
676 break;
677 case 'none':
678 break;
679 case 'action_id':
680 $sql .= " ORDER BY a.action_id $order";
681 break;
682 case 'date':
683 default:
684 $sql .= " ORDER BY a.scheduled_date_gmt $order";
685 break;
686 }
687
688 if ( $query['per_page'] > 0 ) {
689 $sql .= ' LIMIT %d, %d';
690 $sql_params[] = $query['offset'];
691 $sql_params[] = $query['per_page'];
692 }
693 }
694
695 if ( ! empty( $sql_params ) ) {
696 $sql = $wpdb->prepare( $sql, $sql_params ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
697 }
698
699 return $sql;
700 }
701
702 /**
703 * Query for action count or list of action IDs.
704 *
705 * @since 3.3.0 $query['status'] accepts array of statuses instead of a single status.
706 *
707 * @see ActionScheduler_Store::query_actions for $query arg usage.
708 *
709 * @param array $query Query filtering options.
710 * @param string $query_type Whether to select or count the results. Defaults to select.
711 *
712 * @return string|array|null The IDs of actions matching the query. Null on failure.
713 */
714 public function query_actions( $query = array(), $query_type = 'select' ) {
715 /**
716 * Global.
717 *
718 * @var wpdb $wpdb
719 */
720 global $wpdb;
721
722 $sql = $this->get_query_actions_sql( $query, $query_type );
723
724 return ( 'count' === $query_type ) ? $wpdb->get_var( $sql ) : $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoSql, WordPress.DB.DirectDatabaseQuery.NoCaching
725 }
726
727 /**
728 * Get a count of all actions in the store, grouped by status.
729 *
730 * @return array Set of 'status' => int $count pairs for statuses with 1 or more actions of that status.
731 */
732 public function action_counts() {
733 global $wpdb;
734
735 $sql = "SELECT a.status, count(a.status) as 'count'";
736 $sql .= " FROM {$wpdb->actionscheduler_actions} a";
737 $sql .= ' GROUP BY a.status';
738
739 $actions_count_by_status = array();
740 $action_stati_and_labels = $this->get_status_labels();
741
742 foreach ( $wpdb->get_results( $sql ) as $action_data ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
743 // Ignore any actions with invalid status.
744 if ( array_key_exists( $action_data->status, $action_stati_and_labels ) ) {
745 $actions_count_by_status[ $action_data->status ] = $action_data->count;
746 }
747 }
748
749 return $actions_count_by_status;
750 }
751
752 /**
753 * Cancel an action.
754 *
755 * @param int $action_id Action ID.
756 *
757 * @return void
758 * @throws \InvalidArgumentException If the action update failed.
759 */
760 public function cancel_action( $action_id ) {
761 /**
762 * Global.
763 *
764 * @var \wpdb $wpdb
765 */
766 global $wpdb;
767
768 $updated = $wpdb->update(
769 $wpdb->actionscheduler_actions,
770 array( 'status' => self::STATUS_CANCELED ),
771 array( 'action_id' => $action_id ),
772 array( '%s' ),
773 array( '%d' )
774 );
775 if ( false === $updated ) {
776 /* translators: %s: action ID */
777 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to cancel this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
778 }
779 do_action( 'action_scheduler_canceled_action', $action_id );
780 }
781
782 /**
783 * Cancel pending actions by hook.
784 *
785 * @since 3.0.0
786 *
787 * @param string $hook Hook name.
788 *
789 * @return void
790 */
791 public function cancel_actions_by_hook( $hook ) {
792 $this->bulk_cancel_actions( array( 'hook' => $hook ) );
793 }
794
795 /**
796 * Cancel pending actions by group.
797 *
798 * @param string $group Group slug.
799 *
800 * @return void
801 */
802 public function cancel_actions_by_group( $group ) {
803 $this->bulk_cancel_actions( array( 'group' => $group ) );
804 }
805
806 /**
807 * Bulk cancel actions.
808 *
809 * @since 3.0.0
810 *
811 * @param array $query_args Query parameters.
812 */
813 protected function bulk_cancel_actions( $query_args ) {
814 /**
815 * Global.
816 *
817 * @var \wpdb $wpdb
818 */
819 global $wpdb;
820
821 if ( ! is_array( $query_args ) ) {
822 return;
823 }
824
825 // Don't cancel actions that are already canceled.
826 if ( isset( $query_args['status'] ) && self::STATUS_CANCELED === $query_args['status'] ) {
827 return;
828 }
829
830 $action_ids = true;
831 $query_args = wp_parse_args(
832 $query_args,
833 array(
834 'per_page' => 1000,
835 'status' => self::STATUS_PENDING,
836 'orderby' => 'none',
837 )
838 );
839
840 while ( $action_ids ) {
841 $action_ids = $this->query_actions( $query_args );
842 if ( empty( $action_ids ) ) {
843 break;
844 }
845
846 $format = array_fill( 0, count( $action_ids ), '%d' );
847 $query_in = '(' . implode( ',', $format ) . ')';
848 $parameters = $action_ids;
849 array_unshift( $parameters, self::STATUS_CANCELED );
850
851 $wpdb->query(
852 $wpdb->prepare(
853 "UPDATE {$wpdb->actionscheduler_actions} SET status = %s WHERE action_id IN {$query_in}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
854 $parameters
855 )
856 );
857
858 do_action( 'action_scheduler_bulk_cancel_actions', $action_ids );
859 }
860 }
861
862 /**
863 * Delete an action.
864 *
865 * @param int $action_id Action ID.
866 * @throws \InvalidArgumentException If the action deletion failed.
867 */
868 public function delete_action( $action_id ) {
869 /**
870 * Global.
871 *
872 * @var \wpdb $wpdb
873 */
874 global $wpdb;
875
876 $deleted = $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ), array( '%d' ) );
877 if ( empty( $deleted ) ) {
878 /* translators: %s is the action ID */
879 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to delete this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
880 }
881 do_action( 'action_scheduler_deleted_action', $action_id );
882 }
883
884 /**
885 * Get the schedule date for an action.
886 *
887 * @param string $action_id Action ID.
888 *
889 * @return \DateTime The local date the action is scheduled to run, or the date that it ran.
890 */
891 public function get_date( $action_id ) {
892 $date = $this->get_date_gmt( $action_id );
893 ActionScheduler_TimezoneHelper::set_local_timezone( $date );
894 return $date;
895 }
896
897 /**
898 * Get the GMT schedule date for an action.
899 *
900 * @param int $action_id Action ID.
901 *
902 * @throws \InvalidArgumentException If action cannot be identified.
903 * @return \DateTime The GMT date the action is scheduled to run, or the date that it ran.
904 */
905 protected function get_date_gmt( $action_id ) {
906 /**
907 * Global.
908 *
909 * @var \wpdb $wpdb
910 */
911 global $wpdb;
912
913 $record = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d", $action_id ) );
914 if ( empty( $record ) ) {
915 /* translators: %s is the action ID */
916 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to determine the date of this action. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
917 }
918 if ( self::STATUS_PENDING === $record->status ) {
919 return as_get_datetime_object( $record->scheduled_date_gmt );
920 } else {
921 return as_get_datetime_object( $record->last_attempt_gmt );
922 }
923 }
924
925 /**
926 * Stake a claim on actions.
927 *
928 * @param int $max_actions Maximum number of action to include in claim.
929 * @param DateTime|null $before_date Jobs must be schedule before this date. Defaults to now.
930 * @param array $hooks Hooks to filter for.
931 * @param string $group Group to filter for.
932 *
933 * @return ActionScheduler_ActionClaim
934 */
935 public function stake_claim( $max_actions = 10, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
936 $claim_id = $this->generate_claim_id();
937
938 $this->claim_before_date = $before_date;
939 $this->claim_actions( $claim_id, $max_actions, $before_date, $hooks, $group );
940 $action_ids = $this->find_actions_by_claim_id( $claim_id );
941 $this->claim_before_date = null;
942
943 return new ActionScheduler_ActionClaim( $claim_id, $action_ids );
944 }
945
946 /**
947 * Generate a new action claim.
948 *
949 * @return int Claim ID.
950 */
951 protected function generate_claim_id() {
952 /**
953 * Global.
954 *
955 * @var \wpdb $wpdb
956 */
957 global $wpdb;
958
959 $now = as_get_datetime_object();
960 $wpdb->insert( $wpdb->actionscheduler_claims, array( 'date_created_gmt' => $now->format( 'Y-m-d H:i:s' ) ) );
961
962 return $wpdb->insert_id;
963 }
964
965 /**
966 * Set a claim filter.
967 *
968 * @param string $filter_name Claim filter name.
969 * @param mixed $filter_values Values to filter.
970 * @return void
971 */
972 public function set_claim_filter( $filter_name, $filter_values ) {
973 if ( isset( $this->claim_filters[ $filter_name ] ) ) {
974 $this->claim_filters[ $filter_name ] = $filter_values;
975 }
976 }
977
978 /**
979 * Get the claim filter value.
980 *
981 * @param string $filter_name Claim filter name.
982 * @return mixed
983 */
984 public function get_claim_filter( $filter_name ) {
985 if ( isset( $this->claim_filters[ $filter_name ] ) ) {
986 return $this->claim_filters[ $filter_name ];
987 }
988
989 return '';
990 }
991
992 /**
993 * Mark actions claimed.
994 *
995 * @param string $claim_id Claim Id.
996 * @param int $limit Number of action to include in claim.
997 * @param DateTime|null $before_date Should use UTC timezone.
998 * @param array $hooks Hooks to filter for.
999 * @param string $group Group to filter for.
1000 *
1001 * @return int The number of actions that were claimed.
1002 * @throws \InvalidArgumentException Throws InvalidArgumentException if group doesn't exist.
1003 * @throws \RuntimeException Throws RuntimeException if unable to claim action.
1004 */
1005 protected function claim_actions( $claim_id, $limit, ?DateTime $before_date = null, $hooks = array(), $group = '' ) {
1006 /**
1007 * Global.
1008 *
1009 * @var \wpdb $wpdb
1010 */
1011 global $wpdb;
1012 $now = as_get_datetime_object();
1013 $date = is_null( $before_date ) ? $now : clone $before_date;
1014
1015 // Set claim filters.
1016 if ( ! empty( $hooks ) ) {
1017 $this->set_claim_filter( 'hooks', $hooks );
1018 } else {
1019 $hooks = $this->get_claim_filter( 'hooks' );
1020 }
1021 if ( ! empty( $group ) ) {
1022 $this->set_claim_filter( 'group', $group );
1023 } else {
1024 $group = $this->get_claim_filter( 'group' );
1025 }
1026
1027 $where = 'WHERE claim_id = 0 AND scheduled_date_gmt <= %s AND status=%s';
1028 $where_params = array(
1029 $date->format( 'Y-m-d H:i:s' ),
1030 self::STATUS_PENDING,
1031 );
1032
1033 if ( ! empty( $hooks ) ) {
1034 $placeholders = array_fill( 0, count( $hooks ), '%s' );
1035 $where .= ' AND hook IN (' . join( ', ', $placeholders ) . ')';
1036 $where_params = array_merge( $where_params, array_values( $hooks ) );
1037 }
1038
1039 $group_operator = 'IN';
1040 if ( empty( $group ) ) {
1041 $group = $this->get_claim_filter( 'exclude-groups' );
1042 $group_operator = 'NOT IN';
1043 }
1044
1045 if ( ! empty( $group ) ) {
1046 $group_ids = $this->get_group_ids( $group, false );
1047
1048 // throw exception if no matching group(s) found, this matches ActionScheduler_wpPostStore's behaviour.
1049 if ( empty( $group_ids ) ) {
1050 throw new InvalidArgumentException(
1051 sprintf(
1052 /* translators: %s: group name(s) */
1053 _n(
1054 'The group "%s" does not exist.',
1055 'The groups "%s" do not exist.',
1056 is_array( $group ) ? count( $group ) : 1,
1057 'action-scheduler'
1058 ),
1059 $group
1060 )
1061 );
1062 }
1063
1064 $id_list = implode( ',', array_map( 'intval', $group_ids ) );
1065 $where .= " AND group_id {$group_operator} ( $id_list )";
1066 }
1067
1068 /**
1069 * Sets the order-by clause used in the action claim query.
1070 *
1071 * @param string $order_by_sql
1072 * @param string $claim_id Claim Id.
1073 * @param array $hooks Hooks to filter for.
1074 *
1075 * @since 3.8.3 Made $claim_id and $hooks available.
1076 * @since 3.4.0
1077 */
1078 $order = apply_filters( 'action_scheduler_claim_actions_order_by', 'ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC', $claim_id, $hooks );
1079 $skip_locked = $this->db_supports_skip_locked() ? ' SKIP LOCKED' : '';
1080
1081 // Selecting the action_ids that we plan to claim, while skipping any locked rows to avoid deadlocking.
1082 $select_sql = $wpdb->prepare( "SELECT action_id from {$wpdb->actionscheduler_actions} {$where} {$order} LIMIT %d FOR UPDATE{$skip_locked}", array_merge( $where_params, array( $limit ) ) );
1083
1084 // Now place it into an UPDATE statement by joining the result sets, allowing for the SKIP LOCKED behavior to take effect.
1085 $update_sql = "UPDATE {$wpdb->actionscheduler_actions} t1 JOIN ( $select_sql ) t2 ON t1.action_id = t2.action_id SET claim_id=%d, last_attempt_gmt=%s, last_attempt_local=%s";
1086 $update_params = array(
1087 $claim_id,
1088 $now->format( 'Y-m-d H:i:s' ),
1089 current_time( 'mysql' ),
1090 );
1091
1092 $rows_affected = $wpdb->query( $wpdb->prepare( $update_sql, $update_params ) );
1093 if ( false === $rows_affected ) {
1094 $error = empty( $wpdb->last_error )
1095 ? _x( 'unknown', 'database error', 'action-scheduler' )
1096 : $wpdb->last_error;
1097 throw new \RuntimeException(
1098 sprintf(
1099 /* translators: %s database error. */
1100 __( 'Unable to claim actions. Database error: %s.', 'action-scheduler' ),
1101 $error
1102 )
1103 );
1104 }
1105
1106 return (int) $rows_affected;
1107 }
1108
1109 /**
1110 * Determines whether the database supports using SKIP LOCKED. This logic mimicks the $wpdb::has_cap() logic.
1111 *
1112 * SKIP_LOCKED support was added to MariaDB in 10.6.0 and to MySQL in 8.0.1
1113 *
1114 * @return bool
1115 */
1116 private function db_supports_skip_locked() {
1117 global $wpdb;
1118 $db_version = $wpdb->db_version();
1119 $db_server_info = $wpdb->db_server_info();
1120 $is_mariadb = ( false !== strpos( $db_server_info, 'MariaDB' ) );
1121
1122 if ( $is_mariadb &&
1123 '5.5.5' === $db_version &&
1124 PHP_VERSION_ID < 80016 // PHP 8.0.15 or older.
1125 ) {
1126 /*
1127 * Account for MariaDB version being prefixed with '5.5.5-' on older PHP versions.
1128 */
1129 $db_server_info = preg_replace( '/^5\.5\.5-(.*)/', '$1', $db_server_info );
1130 $db_version = preg_replace( '/[^0-9.].*/', '', $db_server_info );
1131 }
1132
1133 $is_supported = ( $is_mariadb && version_compare( $db_version, '10.6.0', '>=' ) ) ||
1134 ( ! $is_mariadb && version_compare( $db_version, '8.0.1', '>=' ) );
1135
1136 /**
1137 * Filter whether the database supports the SKIP LOCKED modifier for queries.
1138 *
1139 * @param bool $is_supported Whether SKIP LOCKED is supported.
1140 *
1141 * @since 3.9.3
1142 */
1143 return apply_filters( 'action_scheduler_db_supports_skip_locked', $is_supported );
1144 }
1145
1146 /**
1147 * Get the number of active claims.
1148 *
1149 * @return int
1150 */
1151 public function get_claim_count() {
1152 global $wpdb;
1153
1154 $sql = "
1155 SELECT COUNT(*)
1156 FROM {$wpdb->actionscheduler_claims} c
1157 WHERE EXISTS (
1158 SELECT 1
1159 FROM {$wpdb->actionscheduler_actions} a
1160 WHERE a.claim_id = c.claim_id
1161 AND a.status IN ( %s, %s )
1162 )
1163 ";
1164 $sql = $wpdb->prepare( $sql, array( self::STATUS_PENDING, self::STATUS_RUNNING ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1165
1166 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1167 }
1168
1169 /**
1170 * Return an action's claim ID, as stored in the claim_id column.
1171 *
1172 * @param string $action_id Action ID.
1173 * @return mixed
1174 */
1175 public function get_claim_id( $action_id ) {
1176 /**
1177 * Global.
1178 *
1179 * @var \wpdb $wpdb
1180 */
1181 global $wpdb;
1182
1183 $sql = "SELECT claim_id FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d";
1184 $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1185
1186 return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1187 }
1188
1189 /**
1190 * Retrieve the action IDs of action in a claim.
1191 *
1192 * @param int $claim_id Claim ID.
1193 * @return int[]
1194 */
1195 public function find_actions_by_claim_id( $claim_id ) {
1196 /**
1197 * Global.
1198 *
1199 * @var \wpdb $wpdb
1200 */
1201 global $wpdb;
1202
1203 $action_ids = array();
1204 $before_date = isset( $this->claim_before_date ) ? $this->claim_before_date : as_get_datetime_object();
1205 $cut_off = $before_date->format( 'Y-m-d H:i:s' );
1206
1207 $sql = $wpdb->prepare(
1208 "SELECT action_id, scheduled_date_gmt FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d ORDER BY priority ASC, attempts ASC, scheduled_date_gmt ASC, action_id ASC",
1209 $claim_id
1210 );
1211
1212 // Verify that the scheduled date for each action is within the expected bounds (in some unusual
1213 // cases, we cannot depend on MySQL to honor all of the WHERE conditions we specify).
1214 foreach ( $wpdb->get_results( $sql ) as $claimed_action ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1215 if ( $claimed_action->scheduled_date_gmt <= $cut_off ) {
1216 $action_ids[] = absint( $claimed_action->action_id );
1217 }
1218 }
1219
1220 return $action_ids;
1221 }
1222
1223 /**
1224 * Release pending actions from a claim and delete the claim.
1225 *
1226 * @param ActionScheduler_ActionClaim $claim Claim object.
1227 * @throws \RuntimeException When unable to release actions from claim.
1228 */
1229 public function release_claim( ActionScheduler_ActionClaim $claim ) {
1230 /**
1231 * Global.
1232 *
1233 * @var \wpdb $wpdb
1234 */
1235 global $wpdb;
1236
1237 if ( 0 === intval( $claim->get_id() ) ) {
1238 // Verify that the claim_id is valid before attempting to release it.
1239 return;
1240 }
1241
1242 /**
1243 * Deadlock warning: This function modifies actions to release them from claims that have been processed. Earlier, we used to it in a atomic query, i.e. we would update all actions belonging to a particular claim_id with claim_id = 0.
1244 * While this was functionally correct, it would cause deadlock, since this update query will hold a lock on the claim_id_.. index on the action table.
1245 * This allowed the possibility of a race condition, where the claimer query is also running at the same time, then the claimer query will also try to acquire a lock on the claim_id_.. index, and in this case if claim release query has already progressed to the point of acquiring the lock, but have not updated yet, it would cause a deadlock.
1246 *
1247 * We resolve this by getting all the actions_id that we want to release claim from in a separate query, and then releasing the claim on each of them. This way, our lock is acquired on the action_id index instead of the claim_id index. Note that the lock on claim_id will still be acquired, but it will only when we actually make the update, rather than when we select the actions.
1248 *
1249 * We only release pending actions in order for them to be claimed by another process.
1250 */
1251 $action_ids = $wpdb->get_col( $wpdb->prepare( "SELECT action_id FROM {$wpdb->actionscheduler_actions} WHERE claim_id = %d AND status = %s", $claim->get_id(), self::STATUS_PENDING ) );
1252
1253 $row_updates = 0;
1254 if ( count( $action_ids ) > 0 ) {
1255 $action_id_string = implode( ',', array_map( 'absint', $action_ids ) );
1256 $row_updates = $wpdb->query( "UPDATE {$wpdb->actionscheduler_actions} SET claim_id = 0 WHERE action_id IN ({$action_id_string})" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1257 }
1258
1259 $wpdb->delete( $wpdb->actionscheduler_claims, array( 'claim_id' => $claim->get_id() ), array( '%d' ) );
1260
1261 if ( $row_updates < count( $action_ids ) ) {
1262 throw new RuntimeException(
1263 sprintf(
1264 // translators: %d is an id.
1265 __( 'Unable to release actions from claim id %d.', 'action-scheduler' ),
1266 $claim->get_id()
1267 )
1268 );
1269 }
1270 }
1271
1272 /**
1273 * Remove the claim from an action.
1274 *
1275 * @param int $action_id Action ID.
1276 *
1277 * @return void
1278 */
1279 public function unclaim_action( $action_id ) {
1280 /**
1281 * Global.
1282 *
1283 * @var \wpdb $wpdb
1284 */
1285 global $wpdb;
1286
1287 $wpdb->update(
1288 $wpdb->actionscheduler_actions,
1289 array( 'claim_id' => 0 ),
1290 array( 'action_id' => $action_id ),
1291 array( '%s' ),
1292 array( '%d' )
1293 );
1294 }
1295
1296 /**
1297 * Mark an action as failed.
1298 *
1299 * @param int $action_id Action ID.
1300 * @throws \InvalidArgumentException Throw an exception if action was not updated.
1301 */
1302 public function mark_failure( $action_id ) {
1303 /**
1304 * Global.
1305
1306 * @var \wpdb $wpdb
1307 */
1308 global $wpdb;
1309
1310 $updated = $wpdb->update(
1311 $wpdb->actionscheduler_actions,
1312 array(
1313 'status' => self::STATUS_FAILED,
1314 'last_attempt_gmt' => current_time( 'mysql', true ),
1315 'last_attempt_local' => current_time( 'mysql' ),
1316 ),
1317 array( 'action_id' => $action_id ),
1318 array( '%s', '%s', '%s' ),
1319 array( '%d' )
1320 );
1321 if ( empty( $updated ) ) {
1322 /* translators: %s is the action ID */
1323 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having failed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
1324 }
1325 }
1326
1327 /**
1328 * Add execution message to action log.
1329 *
1330 * @throws Exception If the action status cannot be updated to self::STATUS_RUNNING ('in-progress').
1331 *
1332 * @param int $action_id Action ID.
1333 *
1334 * @return void
1335 */
1336 public function log_execution( $action_id ) {
1337 /**
1338 * Global.
1339 *
1340 * @var \wpdb $wpdb
1341 */
1342 global $wpdb;
1343
1344 $sql = "UPDATE {$wpdb->actionscheduler_actions} SET attempts = attempts+1, status=%s, last_attempt_gmt = %s, last_attempt_local = %s WHERE action_id = %d";
1345 $sql = $wpdb->prepare( $sql, self::STATUS_RUNNING, current_time( 'mysql', true ), current_time( 'mysql' ), $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1346
1347 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1348 $status_updated = $wpdb->query( $sql );
1349
1350 if ( ! $status_updated ) {
1351 throw new Exception(
1352 sprintf(
1353 /* translators: 1: action ID. 2: status slug. */
1354 __( 'Unable to update the status of action %1$d to %2$s.', 'action-scheduler' ),
1355 $action_id,
1356 self::STATUS_RUNNING
1357 )
1358 );
1359 }
1360 }
1361
1362 /**
1363 * Mark an action as complete.
1364 *
1365 * @param int $action_id Action ID.
1366 *
1367 * @return void
1368 * @throws \InvalidArgumentException Throw an exception if action was not updated.
1369 */
1370 public function mark_complete( $action_id ) {
1371 /**
1372 * Global.
1373 *
1374 * @var \wpdb $wpdb
1375 */
1376 global $wpdb;
1377
1378 $updated = $wpdb->update(
1379 $wpdb->actionscheduler_actions,
1380 array(
1381 'status' => self::STATUS_COMPLETE,
1382 'last_attempt_gmt' => current_time( 'mysql', true ),
1383 'last_attempt_local' => current_time( 'mysql' ),
1384 ),
1385 array( 'action_id' => $action_id ),
1386 array( '%s', '%s', '%s' ),
1387 array( '%d' )
1388 );
1389 if ( empty( $updated ) ) {
1390 /* translators: %s is the action ID */
1391 throw new \InvalidArgumentException( sprintf( __( 'Unidentified action %s: we were unable to mark this action as having completed. It may may have been deleted by another process.', 'action-scheduler' ), $action_id ) );
1392 }
1393
1394 /**
1395 * Fires after a scheduled action has been completed.
1396 *
1397 * @since 3.4.2
1398 *
1399 * @param int $action_id Action ID.
1400 */
1401 do_action( 'action_scheduler_completed_action', $action_id );
1402 }
1403
1404 /**
1405 * Get an action's status.
1406 *
1407 * @param int $action_id Action ID.
1408 *
1409 * @return string
1410 * @throws \InvalidArgumentException Throw an exception if not status was found for action_id.
1411 * @throws \RuntimeException Throw an exception if action status could not be retrieved.
1412 */
1413 public function get_status( $action_id ) {
1414 /**
1415 * Global.
1416 *
1417 * @var \wpdb $wpdb
1418 */
1419 global $wpdb;
1420
1421 $sql = "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id=%d";
1422 $sql = $wpdb->prepare( $sql, $action_id ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1423 $status = $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1424
1425 if ( is_null( $status ) ) {
1426 throw new \InvalidArgumentException( __( 'Invalid action ID. No status found.', 'action-scheduler' ) );
1427 } elseif ( empty( $status ) ) {
1428 throw new \RuntimeException( __( 'Unknown status found for action.', 'action-scheduler' ) );
1429 } else {
1430 return $status;
1431 }
1432 }
1433 }
1434