PluginProbe
Hostinger Tools / 3.0.78
Hostinger Tools v3.0.78
3.0.78 3.0.77 3.0.76 3.0.75 3.0.74 3.0.73 3.0.72 3.0.71 3.0.70 3.0.69 3.0.68 3.0.67 3.0.66 1.8.1 1.8.2 1.8.3 1.9.1 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 All 109 releases
hostinger / vendor / woocommerce / action-scheduler / classes / data-stores / ActionScheduler_DBStore.php

ActionScheduler_DBStore.php in Hostinger Tools 3.0.78, at vendor/woocommerce/action-scheduler/classes/data-stores/ActionScheduler_DBStore.php

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