PluginProbe
ElasticPress / 4.5.0
ElasticPress v4.5.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / IndexHelper.php

IndexHelper.php in ElasticPress 4.5.0, at includes/classes/IndexHelper.php

1,338 lines 39.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Index Helper
4 *
5 * @since 4.0.0
6 * @see docs/indexing-process.md
7 * @see https://10up.github.io/ElasticPress/tutorial-indexing-process.html
8 * @package elasticpress
9 */
10
11 namespace ElasticPress;
12
13 use ElasticPress\Utils as Utils;
14
15 /**
16 * Index Helper Class.
17 *
18 * @since 4.0.0
19 */
20 class IndexHelper {
21 /**
22 * Array to hold all the index sync information.
23 *
24 * @since 4.0.0
25 * @var array|bool
26 */
27 protected $index_meta = false;
28
29 /**
30 * Arguments to be used during the index process.
31 *
32 * @var array
33 */
34 protected $args = [];
35
36 /**
37 * Queried objects of the current sync item in the stack.
38 *
39 * @since 4.0.0
40 * @var array
41 */
42 protected $current_query = [];
43
44 /**
45 * Holds temporary wp_actions when indexing with pagination
46 *
47 * @since 4.0.0
48 * @var array
49 */
50 private $temporary_wp_actions = [];
51
52 /**
53 * Initialize class.
54 *
55 * @since 4.0.0
56 */
57 public function setup() {
58 $this->index_meta = Utils\get_indexing_status();
59 }
60
61 /**
62 * Method to index everything.
63 *
64 * @since 4.0.0
65 * @param array $args Arguments.
66 */
67 public function full_index( $args ) {
68 register_shutdown_function( [ $this, 'handle_index_error' ] );
69 add_filter( 'wp_php_error_message', [ $this, 'wp_handle_index_error' ], 10, 2 );
70
71 $this->index_meta = Utils\get_indexing_status();
72
73 /**
74 * Filter the sync arguments
75 *
76 * @since 4.5.0
77 * @hook ep_sync_args
78 * @param {array} $args Sync arguments
79 * @param {array} $index_meta Current index meta
80 * @return {array} New sync arguments
81 */
82 $this->args = apply_filters( 'ep_sync_args', $args, $this->index_meta );
83
84 if ( false === $this->index_meta ) {
85 $this->build_index_meta();
86 }
87
88 while ( $this->has_items_to_be_processed() ) {
89 $this->process_sync_item();
90 }
91
92 while ( $this->has_network_alias_to_be_created() ) {
93 $this->create_network_alias();
94 }
95
96 $this->full_index_complete();
97 }
98
99 /**
100 * Method to stack everything that needs to be indexed.
101 *
102 * @since 4.0.0
103 */
104 protected function build_index_meta() {
105 Utils\update_option( 'ep_last_sync', time() );
106 Utils\delete_option( 'ep_need_upgrade_sync' );
107 Utils\delete_option( 'ep_feature_auto_activated_sync' );
108 delete_transient( 'ep_sync_interrupted' );
109
110 $start_date_time = date_create( 'now', wp_timezone() );
111
112 /**
113 * There are two ways to control pagination of things that need to be indexed:
114 * - offset: The number of items to skip on each iteration
115 * - id range: Given an ID range, process a batch and set the upper limit as the last processed ID -1
116 *
117 * Although in the first case offset is updated to really control the flow, in the
118 * second it is updated to simply output the number of items processed.
119 */
120 $pagination_method = ( ! empty( $this->args['offset'] ) || ! empty( $this->args['post-ids'] ) || ! empty( $this->args['include'] ) ) ?
121 'offset' :
122 'id_range';
123
124 $starting_indices = array_intersect(
125 Elasticsearch::factory()->get_index_names( 'all' ),
126 wp_list_pluck( Elasticsearch::factory()->get_cluster_indices(), 'index' )
127 );
128
129 $this->index_meta = [
130 'method' => ! empty( $this->args['method'] ) ? $this->args['method'] : 'web',
131 'put_mapping' => ! empty( $this->args['put_mapping'] ),
132 'offset' => ! empty( $this->args['offset'] ) ? absint( $this->args['offset'] ) : 0,
133 'pagination_method' => $pagination_method,
134 'start' => true,
135 'sync_stack' => [],
136 'network_alias' => [],
137 'start_time' => microtime( true ),
138 'start_date_time' => $start_date_time ? $start_date_time->format( DATE_ATOM ) : false,
139 'starting_indices' => $starting_indices,
140 'totals' => [
141 'total' => 0,
142 'synced' => 0,
143 'skipped' => 0,
144 'failed' => 0,
145 'total_time' => 0,
146 'errors' => [],
147 ],
148 ];
149
150 $global_indexables = $this->filter_indexables( Indexables::factory()->get_all( true, true, 'all' ) );
151 $non_global_indexables = $this->filter_indexables( Indexables::factory()->get_all( false, true, 'all' ) );
152
153 $is_network_wide = isset( $this->args['network_wide'] ) && ! is_null( $this->args['network_wide'] );
154
155 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK && $is_network_wide ) {
156 if ( ! is_numeric( $this->args['network_wide'] ) ) {
157 $this->args['network_wide'] = 0;
158 }
159
160 $sites = Utils\get_sites( $this->args['network_wide'] );
161
162 foreach ( $sites as $site ) {
163 if ( ! Utils\is_site_indexable( $site['blog_id'] ) ) {
164 continue;
165 }
166
167 switch_to_blog( $site['blog_id'] );
168
169 foreach ( $non_global_indexables as $indexable ) {
170 $this->add_sync_item_to_stack(
171 [
172 'url' => untrailingslashit( $site['domain'] . $site['path'] ),
173 'blog_id' => (int) $site['blog_id'],
174 'indexable' => $indexable,
175 ]
176 );
177
178 if ( Indexables::factory()->is_active( $indexable ) && ! in_array( $indexable, $this->index_meta['network_alias'], true ) ) {
179 $this->index_meta['network_alias'][] = $indexable;
180 }
181 }
182 }
183
184 restore_current_blog();
185 } else {
186 foreach ( $non_global_indexables as $indexable ) {
187 $this->add_sync_item_to_stack(
188 [
189 'url' => untrailingslashit( home_url() ),
190 'blog_id' => (int) get_current_blog_id(),
191 'indexable' => $indexable,
192 ]
193 );
194 }
195 }
196
197 foreach ( $global_indexables as $indexable ) {
198 $this->add_sync_item_to_stack(
199 [
200 'indexable' => $indexable,
201 ]
202 );
203 }
204
205 $this->index_meta['current_sync_item'] = false;
206 /**
207 * Fires at start of new index
208 *
209 * @since 4.0.0
210 *
211 * @hook ep_sync_start_index
212 * @param {array} $index_meta Index meta information
213 */
214 do_action( 'ep_sync_start_index', $this->index_meta );
215
216 /**
217 * Fires at start of new index
218 *
219 * @since 2.1 Previously called only as 'ep_dashboard_start_index'
220 * @since 4.0.0 Made available for all methods
221 *
222 * @hook ep_{$index_method}_start_index
223 * @param {array} $index_meta Index meta information
224 */
225 do_action( "ep_{$this->args['method']}_start_index", $this->index_meta );
226
227 /**
228 * Filter index meta during dashboard sync
229 *
230 * @since 3.0
231 * @hook ep_index_meta
232 * @param {array} $index_meta Current index meta
233 * @return {array} New index meta
234 */
235 $this->index_meta = apply_filters( 'ep_index_meta', $this->index_meta );
236 }
237
238 /**
239 * Given an array of indexables, check if they are part of the indexable args or not.
240 *
241 * @since 4.0.0
242 * @param array $indexables Indexable slugs.
243 * @return array
244 */
245 protected function filter_indexables( $indexables ) {
246 return array_filter(
247 $indexables,
248 function( $indexable ) {
249 return empty( $this->args['indexables'] ) || in_array( $indexable, $this->args['indexables'], true );
250 }
251 );
252 }
253
254 /**
255 * Check if there are still items to be processed in the stack.
256 *
257 * @since 4.0.0
258 * @return boolean
259 */
260 protected function has_items_to_be_processed() {
261 return ! empty( $this->index_meta['current_sync_item'] ) || count( $this->index_meta['sync_stack'] ) > 0;
262 }
263
264 /**
265 * Method to process the next item in the stack.
266 *
267 * @since 4.0.0
268 */
269 protected function process_sync_item() {
270 if ( empty( $this->index_meta['current_sync_item'] ) ) {
271 $this->index_meta['current_sync_item'] = array_merge(
272 array_shift( $this->index_meta['sync_stack'] ),
273 [
274 'total' => 0,
275 'synced' => 0,
276 'skipped' => 0,
277 'failed' => 0,
278 'errors' => [],
279 ]
280 );
281
282 $indexable_slug = $this->index_meta['current_sync_item']['indexable'];
283 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
284
285 if ( ! Indexables::factory()->is_active( $indexable_slug ) ) {
286 return $this->process_not_active_indexable_sync_item();
287 } elseif ( ! empty( $this->index_meta['current_sync_item']['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
288 $this->output_success(
289 sprintf(
290 /* translators: 1: Indexable name, 2: Site ID */
291 esc_html__( 'Indexing %1$s on site %2$d…', 'elasticpress' ),
292 esc_html( strtolower( $indexable->labels['plural'] ) ),
293 $this->index_meta['current_sync_item']['blog_id']
294 )
295 );
296 } else {
297 $message_string = ( $indexable->global ) ?
298 /* translators: 1: Indexable name */
299 esc_html__( 'Indexing %1$s (globally)…', 'elasticpress' ) :
300 /* translators: 1: Indexable name */
301 esc_html__( 'Indexing %1$s…', 'elasticpress' );
302
303 $this->output_success(
304 sprintf(
305 /* translators: 1: Indexable name */
306 $message_string,
307 esc_html( strtolower( $indexable->labels['plural'] ) )
308 )
309 );
310 }
311 }
312
313 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK && ! empty( $this->index_meta['current_sync_item']['blog_id'] ) ) {
314 switch_to_blog( $this->index_meta['current_sync_item']['blog_id'] );
315 }
316
317 if ( $this->index_meta['current_sync_item']['put_mapping'] ) {
318 $this->put_mapping();
319 }
320
321 $this->index_objects();
322
323 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK && ! empty( $this->index_meta['current_sync_item']['blog_id'] ) ) {
324 restore_current_blog();
325 }
326 }
327
328 /**
329 * Delete an index and recreate it sending the mapping.
330 *
331 * @since 4.0.0
332 */
333 protected function put_mapping() {
334 $this->index_meta['current_sync_item']['put_mapping'] = false;
335
336 /**
337 * Filter whether we should delete index and send new mapping at the start of the sync
338 *
339 * @since 2.1
340 * @hook ep_skip_index_reset
341 * @param {bool} $skip True means skip
342 * @param {array} $index_meta Current index meta
343 * @return {bool} New skip value
344 */
345 if ( apply_filters( 'ep_skip_index_reset', false, $this->index_meta ) ) {
346 return;
347 }
348
349 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
350
351 $indexable->delete_index();
352 $result = $indexable->put_mapping( 'raw' );
353
354 /**
355 * Fires after sync put mapping is completed
356 *
357 * @since 4.0.0
358 *
359 * @hook ep_sync_put_mapping
360 * @param {array} $index_meta Index meta information
361 * @param {Indexable} $indexable Indexable object
362 * @param {bool} $result Whether the request was successful or not
363 */
364 do_action( 'ep_sync_put_mapping', $this->index_meta, $indexable, $result );
365
366 /**
367 * Fires after dashboard put mapping is completed
368 *
369 * In this particular case, developer aiming a specific method should rely on
370 * `$index_meta['method']`, as historically `ep_dashboard_put_mapping` and
371 * `ep_cli_put_mapping` receive different parameters.
372 *
373 * @see Command::call_ep_cli_put_mapping()
374 *
375 * @since 2.1
376 * @hook ep_dashboard_put_mapping
377 * @param {array} $index_meta Index meta information
378 * @param {string} $status Current indexing status
379 */
380 do_action( 'ep_dashboard_put_mapping', $this->index_meta, 'start' );
381
382 if ( is_wp_error( $result ) ) {
383 $this->on_error_update_and_clean( array( 'message' => $result->get_error_message() ), 'mapping' );
384 return;
385 }
386
387 $index_exists = in_array( $indexable->get_index_name(), $this->index_meta['starting_indices'], true );
388 if ( $index_exists ) {
389 $message = esc_html__( 'Mapping sent', 'elasticpress' );
390 } else {
391 $message = esc_html__( 'Index not present. Mapping sent', 'elasticpress' );
392 }
393
394 $this->output_success( $message );
395 }
396
397 /**
398 * Index documents of an index.
399 *
400 * @since 4.0.0
401 */
402 protected function index_objects() {
403 global $wp_actions;
404 // Hold original wp_actions.
405 $this->temporary_wp_actions = $wp_actions;
406
407 $this->current_query = $this->get_objects_to_index();
408
409 $this->index_meta['from'] = $this->index_meta['offset'];
410 $this->index_meta['found_items'] = (int) $this->current_query['total_objects'];
411 $this->index_meta['current_sync_item']['total'] = (int) $this->index_meta['current_sync_item']['found_items'];
412
413 if ( 'offset' === $this->index_meta['pagination_method'] ) {
414 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
415
416 if ( empty( $this->index_meta['current_sync_item']['shown_skip_message'] ) ) {
417 $this->index_meta['current_sync_item']['shown_skip_message'] = true;
418
419 $this->output(
420 sprintf(
421 /* translators: 1. Number of objects skipped 2. Indexable type */
422 esc_html__( 'Skipping %1$d %2$s…', 'elasticpress' ),
423 $this->index_meta['from'],
424 esc_html( strtolower( $indexable->labels['plural'] ) )
425 ),
426 'info',
427 'index_objects'
428 );
429 }
430 }
431
432 if ( $this->index_meta['found_items'] && $this->index_meta['offset'] < $this->index_meta['found_items'] ) {
433 $this->index_next_batch();
434 } else {
435 $this->index_cleanup();
436 }
437
438 usleep( 500 );
439
440 // Avoid running out of memory.
441 $this->stop_the_insanity();
442 }
443
444 /**
445 * Query the next objects to be indexed.
446 *
447 * @since 4.0.0
448 * @return array
449 */
450 protected function get_objects_to_index() {
451 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
452
453 /**
454 * Fires right before entries are about to be indexed.
455 *
456 * @since 4.0.0
457 *
458 * @hook ep_pre_sync_index
459 * @param {array} $args Args to query content with
460 */
461 do_action( 'ep_pre_sync_index', $this->index_meta, ( $this->index_meta['start'] ? 'start' : false ), $indexable );
462
463 /**
464 * Fires right before entries are about to be indexed.
465 *
466 * @since 2.1 Previously called only as 'ep_pre_dashboard_index'
467 * @since 4.0.0 Made available for all methods
468 *
469 * @hook ep_pre_{$index_method}_index
470 * @param {array} $args Args to query content with
471 */
472 do_action( "ep_pre_{$this->args['method']}_index", $this->index_meta, ( $this->index_meta['start'] ? 'start' : false ), $indexable );
473
474 $per_page = $this->get_index_default_per_page();
475
476 if ( ! empty( $this->args['per_page'] ) ) {
477 $per_page = $this->args['per_page'];
478 }
479
480 if ( ! empty( $this->args['nobulk'] ) ) {
481 $per_page = 1;
482 }
483
484 $args = [
485 'per_page' => absint( $per_page ),
486 'ep_sync_id' => uniqid(),
487 ];
488
489 if ( ! $indexable->support_indexing_advanced_pagination || 'offset' === $this->index_meta['pagination_method'] ) {
490 $args['offset'] = $this->index_meta['offset'];
491 }
492
493 if ( ! empty( $this->args['post-ids'] ) ) {
494 $args['include'] = $this->args['post-ids'];
495 }
496
497 if ( ! empty( $this->args['include'] ) ) {
498 $include = ( is_array( $this->args['include'] ) ) ? $this->args['include'] : explode( ',', str_replace( ' ', '', $this->args['include'] ) );
499 $args['include'] = array_map( 'absint', $include );
500 $args['per_page'] = count( $args['include'] );
501 }
502
503 if ( ! empty( $this->args['post_type'] ) ) {
504 $args['post_type'] = ( is_array( $this->args['post_type'] ) ) ? $this->args['post_type'] : explode( ',', $this->args['post_type'] );
505 $args['post_type'] = array_map( 'trim', $args['post_type'] );
506 }
507
508 // Start of advanced pagination arguments.
509 if ( ! empty( $this->args['upper_limit_object_id'] ) && is_numeric( $this->args['upper_limit_object_id'] ) ) {
510 $args['ep_indexing_upper_limit_object_id'] = $this->args['upper_limit_object_id'];
511 }
512
513 if ( ! empty( $this->args['lower_limit_object_id'] ) && is_numeric( $this->args['lower_limit_object_id'] ) ) {
514 $args['ep_indexing_lower_limit_object_id'] = $this->args['lower_limit_object_id'];
515 }
516
517 if ( ! empty( $this->index_meta['current_sync_item']['last_processed_object_id'] ) &&
518 is_numeric( $this->index_meta['current_sync_item']['last_processed_object_id'] )
519 ) {
520 $args['ep_indexing_last_processed_object_id'] = $this->index_meta['current_sync_item']['last_processed_object_id'];
521 }
522 // End of advanced pagination arguments.
523
524 /**
525 * Filters arguments used to query for content for each indexable
526 *
527 * @since 4.0.0
528 *
529 * @hook ep_sync_index_args
530 * @param {array} $args Args to query content with
531 * @return {array} New query args
532 */
533 $args = apply_filters( 'ep_sync_index_args', $args );
534
535 /**
536 * Filters arguments used to query for content for each indexable
537 *
538 * @since 3.0 Previously called only as 'ep_dashboard_index_args'
539 *
540 * @hook ep_{$index_method}_index_args
541 * @param {array} $args Args to query content with
542 * @return {array} New query args
543 */
544 $args = apply_filters( "ep_{$this->args['method']}_index_args", $args );
545
546 return $indexable->query_db( $args );
547 }
548
549 /**
550 * Index the next batch of documents.
551 *
552 * @since 4.0.0
553 */
554 protected function index_next_batch() {
555 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
556
557 /**
558 * Fires right before entries are about to be indexed in a dashboard sync
559 *
560 * @since 4.0.0
561 * @hook ep_pre_index_batch
562 * @param {array} $index_meta Index meta
563 */
564 do_action( 'ep_pre_index_batch', $this->index_meta );
565
566 $queued_items = [];
567
568 foreach ( $this->current_query['objects'] as $object ) {
569 if ( $this->should_skip_object_index( $object, $indexable ) ) {
570 $this->index_meta['current_sync_item']['skipped']++;
571 } else {
572 $queued_items[ $object->ID ] = true;
573 }
574 }
575
576 $this->index_meta['offset'] = absint( $this->index_meta['offset'] + count( $this->current_query['objects'] ) );
577
578 if ( ! empty( $queued_items ) ) {
579 $total_attempts = ( ! empty( $this->args['total_attempts'] ) ) ? absint( $this->args['total_attempts'] ) : 1;
580 $queued_items_ids = array_keys( $queued_items );
581
582 /**
583 * Filters the number of times the index will try before failing.
584 *
585 * @since 3.0
586 * @hook ep_index_batch_attempts_number
587 * @param {int} $total_attempts Number of attempts
588 * @return {int} New number of attempts
589 */
590 $total_attempts = apply_filters( 'ep_index_batch_attempts_number', $total_attempts );
591
592 for ( $attempts = 1; $attempts <= $total_attempts; $attempts++ ) {
593 $nobulk = ! empty( $this->args['nobulk'] );
594 $failed_objects = [];
595
596 /**
597 * Fires before each attempt of indexing objects
598 *
599 * @hook ep_index_batch_new_attempt
600 * @param {int} $attempts Current attempt
601 * @param {int} $total_attempts Total number of attempts
602 */
603 do_action( 'ep_index_batch_new_attempt', $attempts, $total_attempts );
604
605 $should_retry = false;
606
607 if ( $nobulk ) {
608 $object_id = reset( $queued_items_ids );
609 $return = $indexable->index( $object_id, true );
610
611 /**
612 * Fires after one by one indexing an object
613 *
614 * @since 4.0.0
615 *
616 * @hook ep_sync_object_index
617 * @param {int} $object_id Object to index
618 * @param {Indexable} $indexable Current indexable
619 * @param {mixed} $return Return of the index() call
620 */
621 do_action( 'ep_sync_object_index', $object_id, $indexable, $return );
622
623 /**
624 * Fires after one by one indexing an object
625 *
626 * @since 3.0 Previously called only as 'ep_cli_object_index'
627 * @since 4.0.0 Made available for all methods
628 *
629 * @hook ep_{$index_method}_object_index
630 * @param {int} $object_id Object to index
631 * @param {Indexable} $indexable Current indexable
632 * @param {mixed} $return Return of the index() call
633 */
634 do_action( "ep_{$this->args['method']}_object_index", $object_id, $indexable, $return );
635
636 if ( is_object( $return ) && ! empty( $return->error ) ) {
637 if ( ! empty( $return->error->reason ) ) {
638 $failed_objects[ $object->ID ] = (array) $return->error;
639 } else {
640 $failed_objects[ $object->ID ] = null;
641 }
642 }
643
644 if ( is_wp_error( $return ) ) {
645 $should_retry = true;
646 }
647 } else {
648 if ( ! empty( $this->args['static_bulk'] ) ) {
649 $bulk_requests = [ $indexable->bulk_index( $queued_items_ids ) ];
650 } else {
651 $bulk_requests = $indexable->bulk_index_dynamically( $queued_items_ids );
652 }
653
654 $failed_objects = [];
655 foreach ( $bulk_requests as $return ) {
656 /**
657 * Fires after bulk indexing
658 *
659 * @hook ep_cli_{indexable_slug}_bulk_index
660 * @param {array} $objects Objects being indexed
661 * @param {array} response Elasticsearch bulk index response
662 */
663 do_action( "ep_cli_{$indexable->slug}_bulk_index", $queued_items, $return );
664
665 if ( is_wp_error( $return ) ) {
666 $should_retry = true;
667 }
668 if ( is_array( $return ) && isset( $return['errors'] ) && true === $return['errors'] ) {
669 $failed_objects = array_merge(
670 $failed_objects,
671 array_filter(
672 $return['items'],
673 function( $item ) {
674 return ! empty( $item['index']['error'] );
675 }
676 )
677 );
678 }
679 }
680 }
681
682 // Things worked, we don't need to try again.
683 if ( ! $should_retry && ! count( $failed_objects ) ) {
684 break;
685 }
686 }
687
688 if ( is_wp_error( $return ) ) {
689 $this->index_meta['current_sync_item']['failed'] += count( $queued_items );
690 $this->index_meta['current_sync_item']['errors'] = array_merge( $this->index_meta['current_sync_item']['errors'], $return->get_error_messages() );
691
692 $this->output( implode( "\n", $return->get_error_messages() ), 'warning' );
693 } elseif ( count( $failed_objects ) ) {
694 $errors_output = $this->output_index_errors( $failed_objects );
695
696 $this->index_meta['current_sync_item']['synced'] += count( $queued_items ) - count( $failed_objects );
697 $this->index_meta['current_sync_item']['failed'] += count( $failed_objects );
698 $this->index_meta['current_sync_item']['errors'] = array_merge( $this->index_meta['current_sync_item']['errors'], $errors_output );
699
700 $this->output( $errors_output, 'warning' );
701 } else {
702 $this->index_meta['current_sync_item']['synced'] += count( $queued_items );
703 }
704 }
705
706 $this->index_meta['current_sync_item']['last_processed_object_id'] = end( $this->current_query['objects'] )->ID;
707
708 $this->output(
709 sprintf(
710 /* translators: 1. Indexable type 2. Offset start, 3. Offset end, 4. Found items 5. Last object ID */
711 esc_html__( 'Processed %1$s %2$d - %3$d of %4$d. Last Object ID: %5$d', 'elasticpress' ),
712 esc_html( strtolower( $indexable->labels['plural'] ) ),
713 $this->index_meta['from'],
714 $this->index_meta['offset'],
715 $this->index_meta['found_items'],
716 $this->index_meta['current_sync_item']['last_processed_object_id']
717 ),
718 'info',
719 'index_next_batch'
720 );
721 }
722
723 /**
724 * Update the sync info with the totals from the last sync item.
725 *
726 * @since 4.2.0
727 */
728 protected function update_totals_from_current_sync_item() {
729 $current_sync_item = $this->index_meta['current_sync_item'];
730
731 $errors = array_merge(
732 $this->index_meta['totals']['errors'],
733 $current_sync_item['errors']
734 );
735
736 /**
737 * Filter the number of errors of a sync that should be stored.
738 *
739 * @since 4.2.0
740 * @hook ep_sync_number_of_errors_stored
741 * @param {int} $number Number of errors to be logged.
742 * @return {int} New value
743 */
744 $logged_errors = (int) apply_filters( 'ep_sync_number_of_errors_stored', 50 );
745
746 $this->index_meta['totals']['total'] += $current_sync_item['total'];
747 $this->index_meta['totals']['synced'] += $current_sync_item['synced'];
748 $this->index_meta['totals']['skipped'] += $current_sync_item['skipped'];
749 $this->index_meta['totals']['failed'] += $current_sync_item['failed'];
750 $this->index_meta['totals']['errors'] = array_slice( $errors, $logged_errors * -1 );
751 }
752
753 /**
754 * Make the necessary clean up after a sync item of the stack was completely done.
755 *
756 * @since 4.0.0
757 * @return void
758 */
759 protected function index_cleanup() {
760 wp_reset_postdata();
761
762 $this->update_totals_from_current_sync_item();
763
764 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
765
766 $current_sync_item = $this->index_meta['current_sync_item'];
767
768 $this->index_meta['current_sync_item'] = null;
769
770 if ( $current_sync_item['failed'] ) {
771 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
772 $message = sprintf(
773 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of failed objects */
774 esc_html__( 'Number of %1$s index errors on site %2$d: %3$d', 'elasticpress' ),
775 esc_html( strtolower( $indexable->labels['plural'] ) ),
776 $current_sync_item['blog_id'],
777 $current_sync_item['failed']
778 );
779 } else {
780 $message = sprintf(
781 /* translators: 1: indexable (plural), 2: number of failed objects */
782 esc_html__( 'Number of %1$s index errors: %2$d', 'elasticpress' ),
783 esc_html( strtolower( $indexable->labels['plural'] ) ),
784 $current_sync_item['failed']
785 );
786 }
787
788 $this->output( $message, 'warning' );
789 }
790
791 $this->index_meta['offset'] = 0;
792
793 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
794 $message = sprintf(
795 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of synced objects */
796 esc_html__( 'Number of %1$s indexed on site %2$d: %3$d', 'elasticpress' ),
797 esc_html( strtolower( $indexable->labels['plural'] ) ),
798 $current_sync_item['blog_id'],
799 $current_sync_item['synced']
800 );
801 } else {
802 $message = sprintf(
803 /* translators: 1: indexable (plural), 2: number of synced objects */
804 esc_html__( 'Number of %1$s indexed: %2$d', 'elasticpress' ),
805 esc_html( strtolower( $indexable->labels['plural'] ) ),
806 $current_sync_item['synced']
807 );
808 }
809
810 $this->output_success( $message );
811 }
812
813 /**
814 * Update last sync info.
815 *
816 * @since 4.2.0
817 */
818 protected function update_last_index() {
819 $start_time = $this->index_meta['start_time'];
820 $totals = $this->index_meta['totals'];
821 $method = $this->index_meta['method'];
822 $is_full_sync = $this->index_meta['put_mapping'];
823
824 $this->index_meta = null;
825
826 $end_date_time = date_create( 'now', wp_timezone() );
827 $start_time_sec = (int) $start_time;
828
829 $totals['end_date_time'] = $end_date_time ? $end_date_time->format( DATE_ATOM ) : false;
830 $totals['start_date_time'] = $start_time ? wp_date( DATE_ATOM, $start_time_sec ) : false;
831 $totals['end_time_gmt'] = time();
832 $totals['total_time'] = microtime( true ) - $start_time;
833 $totals['method'] = $method;
834 $totals['is_full_sync'] = $is_full_sync;
835 Utils\update_option( 'ep_last_cli_index', $totals, false );
836 Utils\update_option( 'ep_last_index', $totals, false );
837 }
838
839 /**
840 * Make the necessary clean up after everything was sync'd.
841 *
842 * @since 4.0.0
843 */
844 protected function full_index_complete() {
845 $this->update_last_index();
846
847 /**
848 * Fires after executing a reindex
849 *
850 * @since 4.0.0
851 * @hook ep_after_sync_index
852 */
853 do_action( 'ep_after_sync_index' );
854
855 /**
856 * Fires after executing a reindex
857 *
858 * @since 3.5.5 Previously called only as 'ep_after_dashboard_index'
859 * @since 4.0.0 Made available for all methods
860 * @hook ep_after_{$index_method}_index
861 */
862 do_action( "ep_after_{$this->args['method']}_index" );
863
864 $this->output_success( esc_html__( 'Sync complete', 'elasticpress' ) );
865 }
866
867 /**
868 * Check if network aliases need to be created.
869 *
870 * @since 4.0.0
871 * @return boolean
872 */
873 protected function has_network_alias_to_be_created() {
874 return count( $this->index_meta['network_alias'] ) > 0;
875 }
876
877 /**
878 * Create the next network alias.
879 *
880 * @since 4.0.0
881 */
882 protected function create_network_alias() {
883 $indexes = [];
884 $indexable = Indexables::factory()->get( array_shift( $this->index_meta['network_alias'] ) );
885
886 $sites = Utils\get_sites();
887
888 foreach ( $sites as $site ) {
889
890 if ( ! Utils\is_site_indexable( $site['blog_id'] ) ) {
891 continue;
892 }
893
894 switch_to_blog( $site['blog_id'] );
895 $indexes[] = $indexable->get_index_name();
896 restore_current_blog();
897 }
898
899 $result = $indexable->create_network_alias( $indexes );
900
901 if ( $result ) {
902 $this->output_success(
903 sprintf(
904 /* translators: 1: Indexable name */
905 esc_html__( 'Network alias created for %1$s', 'elasticpress' ),
906 esc_html( strtolower( $indexable->labels['plural'] ) )
907 )
908 );
909 } else {
910 $this->output_error(
911 sprintf(
912 /* translators: 1: Indexable name */
913 esc_html__( 'Network alias creation failed for %1$s', 'elasticpress' ),
914 esc_html( strtolower( $indexable->labels['plural'] ) )
915 )
916 );
917 }
918 }
919
920 /**
921 * Output a message.
922 *
923 * @since 4.0.0
924 * @param string|array $message_text Message to be outputted
925 * @param string $type Type of message
926 * @param string $context Context of the output
927 * @return void
928 */
929 protected function output( $message_text, $type = 'info', $context = '' ) {
930 if ( $this->index_meta ) {
931 Utils\update_option( 'ep_index_meta', $this->index_meta );
932 } else {
933 Utils\delete_option( 'ep_index_meta' );
934 $totals = $this->get_last_index();
935 }
936
937 $message = [
938 'message' => ( is_array( $message_text ) ) ? implode( "\n", $message_text ) : $message_text,
939 'index_meta' => $this->index_meta,
940 'totals' => $totals ?? [],
941 'status' => $type,
942 ];
943
944 if ( is_callable( $this->args['output_method'] ) ) {
945 call_user_func( $this->args['output_method'], $message, $this->args, $this->index_meta, $context );
946 }
947 }
948
949 /**
950 * Wrapper to the `output` method with a success message.
951 *
952 * @since 4.0.0
953 * @param string $message Message string.
954 * @param string $context Context of the output.
955 */
956 protected function output_success( $message, $context = '' ) {
957 $this->output( $message, 'success', $context );
958 }
959
960 /**
961 * Wrapper to the `output` method with an error message.
962 *
963 * @since 4.0.0
964 * @param string $message Message string.
965 * @param string $context Context of the output.
966 */
967 protected function output_error( $message, $context = '' ) {
968 $this->output( $message, 'error', $context );
969 }
970
971 /**
972 * Output index errors of failed objects.
973 *
974 * @since 4.0.0
975 * @param array $failed_objects Failed objects
976 */
977 protected function output_index_errors( $failed_objects ) {
978 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
979
980 $error_text = [];
981
982 foreach ( $failed_objects as $object ) {
983 $error_text[] = $object['index']['_id'] . ' (' . $indexable->labels['singular'] . '): [' . $object['index']['error']['type'] . '] ' . $object['index']['error']['reason'];
984 }
985
986 return $error_text;
987 }
988
989 /**
990 * Utilitary function to check if the indexable is being fully reindexed, i.e.,
991 * the index was deleted, a new mapping was sent and content is being reindexed.
992 *
993 * @param string $indexable_slug Indexable slug.
994 * @param int|null $blog_id Blog ID
995 * @return boolean
996 */
997 public function is_full_reindexing( $indexable_slug, $blog_id = null ) {
998 if ( empty( $this->index_meta ) || empty( $this->index_meta['put_mapping'] ) ) {
999 /**
1000 * Filter if a fully reindex is being done to an indexable
1001 *
1002 * @since 4.0.0
1003 * @hook ep_is_full_reindexing_{$indexable_slug}
1004 * @param {bool} $is_full_reindexing If is fully reindexing
1005 * @return {bool} New value
1006 */
1007 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", false );
1008 }
1009
1010 $sync_stack = ( ! empty( $this->index_meta['sync_stack'] ) ) ? $this->index_meta['sync_stack'] : [];
1011 $current_sync_item = ( ! empty( $this->index_meta['current_sync_item'] ) ) ? $this->index_meta['current_sync_item'] : [];
1012
1013 $is_full_reindexing = false;
1014
1015 $all_items = $sync_stack;
1016 if ( ! empty( $current_sync_item ) ) {
1017 $all_items += [ $current_sync_item ];
1018 }
1019
1020 foreach ( $all_items as $sync_item ) {
1021 if ( $sync_item['indexable'] !== $indexable_slug ) {
1022 continue;
1023 }
1024
1025 if (
1026 ( empty( $sync_item['blog_id'] ) && ! $blog_id ) ||
1027 (int) $sync_item['blog_id'] === $blog_id
1028 ) {
1029 $is_full_reindexing = true;
1030 }
1031 }
1032
1033 /* this filter is documented above */
1034 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", $is_full_reindexing );
1035 }
1036
1037 /**
1038 * Get the last index/sync meta information.
1039 *
1040 * @since 4.2.0
1041 * @return array
1042 */
1043 public function get_last_index() {
1044 return Utils\get_option( 'ep_last_index', [] );
1045 }
1046
1047 /**
1048 * Check if an object should be indexed or skipped.
1049 *
1050 * We used to have two different filters for this (one for the dashboard, another for CLI),
1051 * this method combines both.
1052 *
1053 * @param {stdClass} $object Object to be checked
1054 * @param {Indexable} $indexable Indexable
1055 * @return boolean
1056 */
1057 protected function should_skip_object_index( $object, $indexable ) {
1058 /**
1059 * Filter whether to not sync specific item in dashboard or not
1060 *
1061 * @since 2.1
1062 * @hook ep_item_sync_kill
1063 * @param {boolean} $kill False means dont sync
1064 * @param {array} $object Object to sync
1065 * @return {Indexable} Indexable that object belongs to
1066 */
1067 $ep_item_sync_kill = apply_filters( 'ep_item_sync_kill', false, $object, $indexable );
1068
1069 /**
1070 * Conditionally kill indexing for a post
1071 *
1072 * @hook ep_{indexable_slug}_index_kill
1073 * @param {bool} $index True means dont index
1074 * @param {int} $object_id Object ID
1075 * @return {bool} New value
1076 */
1077 $ep_indexable_sync_kill = apply_filters( 'ep_' . $indexable->slug . '_index_kill', false, $object->ID );
1078
1079 return $ep_item_sync_kill || $ep_indexable_sync_kill;
1080 }
1081
1082 /**
1083 * Given an array, create a new sync item and add it to the stack.
1084 *
1085 * @since 4.5.0
1086 * @param array $sync_stack_item The new sync item
1087 */
1088 protected function add_sync_item_to_stack( array $sync_stack_item ) {
1089 $indexable_slug = $sync_stack_item['indexable'];
1090 $indexable_object = Indexables::factory()->get( $indexable_slug );
1091
1092 if ( ! $indexable_object ) {
1093 return;
1094 }
1095
1096 $index_exists = in_array( $indexable_object->get_index_name(), $this->index_meta['starting_indices'], true );
1097
1098 $sync_stack_item['put_mapping'] = ! empty( $this->args['put_mapping'] ) || ! $index_exists;
1099
1100 if ( ! Indexables::factory()->is_active( $indexable_slug ) ) {
1101 array_unshift( $this->index_meta['sync_stack'], $sync_stack_item );
1102 return;
1103 }
1104
1105 // This is needed, because get_objects_to_index() calculates its total based on the current sync item.
1106 $this->index_meta['current_sync_item'] = $sync_stack_item;
1107
1108 $objects_to_index = $this->get_objects_to_index();
1109
1110 $sync_stack_item['found_items'] = $objects_to_index['total_objects'] ?? 0;
1111
1112 $this->index_meta['sync_stack'][] = $sync_stack_item;
1113 }
1114
1115 /**
1116 * Processes an indexable that is not active.
1117 *
1118 * If running a full sync, delete the index of an unused indexable.
1119 *
1120 * @since 4.5.0
1121 */
1122 protected function process_not_active_indexable_sync_item() {
1123 $current_sync_item = $this->index_meta['current_sync_item'];
1124
1125 $this->index_meta['current_sync_item'] = null;
1126
1127 if ( empty( $current_sync_item['put_mapping'] ) ) {
1128 return;
1129 }
1130
1131 $indexable = Indexables::factory()->get( $current_sync_item['indexable'] );
1132
1133 if ( ! in_array( $indexable->get_index_name(), $this->index_meta['starting_indices'], true ) ) {
1134 return;
1135 }
1136
1137 $indexable->delete_index();
1138
1139 $this->output_success(
1140 sprintf(
1141 /* translators: Index name */
1142 esc_html__( 'Index %s deleted', 'elasticpress' ),
1143 $indexable->get_index_name()
1144 )
1145 );
1146 }
1147
1148 /**
1149 * Resets some values to reduce memory footprint.
1150 */
1151 protected function stop_the_insanity() {
1152 global $wpdb, $wp_object_cache, $wp_actions;
1153
1154 $wpdb->queries = [];
1155
1156 /*
1157 * Runtime flushing was introduced in WordPress 6.0 and will flush only the
1158 * in-memory cache for persistent object caches
1159 */
1160 if ( function_exists( 'wp_cache_flush_runtime' ) ) {
1161 wp_cache_flush_runtime();
1162 } else {
1163 /*
1164 * In the case where we're not using an external object cache, we need to call flush on the default
1165 * WordPress object cache class to clear the values from the cache property
1166 */
1167 if ( ! wp_using_ext_object_cache() ) {
1168 wp_cache_flush();
1169 }
1170 }
1171
1172 if ( is_object( $wp_object_cache ) ) {
1173 $wp_object_cache->group_ops = [];
1174 $wp_object_cache->stats = [];
1175 $wp_object_cache->memcache_debug = [];
1176
1177 // Make sure this is a public property, before trying to clear it.
1178 try {
1179 $cache_property = new \ReflectionProperty( $wp_object_cache, 'cache' );
1180 if ( $cache_property->isPublic() ) {
1181 $wp_object_cache->cache = [];
1182 }
1183 unset( $cache_property );
1184 } catch ( \ReflectionException $e ) {
1185 // No need to catch.
1186 }
1187
1188 if ( is_callable( $wp_object_cache, '__remoteset' ) ) {
1189 call_user_func( [ $wp_object_cache, '__remoteset' ] );
1190 }
1191 }
1192
1193 // Prevent wp_actions from growing out of control.
1194 // phpcs:disable
1195 $wp_actions = $this->temporary_wp_actions;
1196 // phpcs:enable
1197
1198 // It's high memory consuming as WP_Query instance holds all query results inside itself
1199 // and in theory $wp_filter will not stop growing until Out Of Memory exception occurs.
1200 remove_filter( 'get_term_metadata', [ wp_metadata_lazyloader(), 'lazyload_term_meta' ] );
1201
1202 /**
1203 * Fires after reducing the memory footprint
1204 *
1205 * @since 4.3.0
1206 * @hook ep_stop_the_insanity
1207 */
1208 do_action( 'ep_stop_the_insanity' );
1209 }
1210
1211 /**
1212 * Utilitary function to delete the index meta option.
1213 *
1214 * @since 4.0.0
1215 */
1216 public function clear_index_meta() {
1217 $this->index_meta = false;
1218 Utils\delete_option( 'ep_index_meta', false );
1219 }
1220
1221 /**
1222 * Utilitary function to get the index meta option.
1223 *
1224 * @return array
1225 * @since 4.0.0
1226 */
1227 public function get_index_meta() {
1228 return Utils\get_option( 'ep_index_meta', [] );
1229 }
1230
1231 /**
1232 * Handle fatal errors during syncs.
1233 *
1234 * Added by register_shutdown_function. It will not be called if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1235 *
1236 * @since 4.2.0
1237 */
1238 public function handle_index_error() {
1239 $error = error_get_last();
1240 if ( empty( $error['type'] ) || E_ERROR !== $error['type'] ) {
1241 return;
1242 }
1243
1244 $this->on_error_update_and_clean( $error );
1245 }
1246
1247 /**
1248 * Handle fatal errors during syncs.
1249 *
1250 * Added via the `wp_php_error_message` filter. It will be called only if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1251 *
1252 * @since 4.2.0
1253 * @param bool $message HTML error message to display.
1254 * @param array $error Error information retrieved from error_get_last().
1255 * @return bool
1256 */
1257 public function wp_handle_index_error( $message, $error ) {
1258 $this->on_error_update_and_clean( $error );
1259 return $message;
1260 }
1261
1262 /**
1263 * Logs the error and clears the sync status, preventing the sync status from being stuck.
1264 *
1265 * @since 4.2.0
1266 * @param array $error Error information retrieved from error_get_last().
1267 * @param string $context Context of the error.
1268 */
1269 protected function on_error_update_and_clean( $error, $context = 'sync' ) {
1270 $this->update_totals_from_current_sync_item();
1271
1272 $totals = $this->index_meta['totals'];
1273
1274 $this->index_meta['totals']['errors'][] = $error['message'];
1275 $this->index_meta['totals']['failed'] = $totals['total'] - ( $totals['synced'] + $totals['skipped'] );
1276 $this->update_last_index();
1277
1278 /**
1279 * Fires after a sync failed due to a PHP fatal error.
1280 *
1281 * @since 4.2.0
1282 * @hook ep_after_sync_error
1283 * @param {array} $error The error
1284 */
1285 do_action( 'ep_after_sync_error', $error );
1286
1287 switch ( $context ) {
1288 case 'mapping':
1289 /* translators: Error message */
1290 $message = sprintf( esc_html__( 'Mapping failed: %s', 'elasticpress' ), $error['message'] );
1291 $message .= "\n";
1292 $message .= esc_html__( 'Mapping has failed, which will cause ElasticPress search results to be incorrect. Please click `Delete all Data and Start a Fresh Sync` to retry mapping.', 'elasticpress' );
1293 break;
1294 default:
1295 /* translators: Error message */
1296 $message = sprintf( esc_html__( 'Index failed: %s', 'elasticpress' ), $error['message'] );
1297 break;
1298 }
1299
1300 $this->output_error( $message );
1301 }
1302
1303 /**
1304 * Return the default number of documents to be sent to Elasticsearch on each batch.
1305 *
1306 * @since 4.4.0
1307 * @return integer
1308 */
1309 public function get_index_default_per_page() : int {
1310 /**
1311 * Filter number of items to index per cycle in the dashboard
1312 *
1313 * @since 2.1
1314 * @hook ep_index_default_per_page
1315 * @param {int} Entries per cycle
1316 * @return {int} New number of entries
1317 */
1318 return (int) apply_filters( 'ep_index_default_per_page', Utils\get_option( 'ep_bulk_setting', 350 ) );
1319 }
1320
1321 /**
1322 * Return singleton instance of class.
1323 *
1324 * @return self
1325 * @since 4.0.0
1326 */
1327 public static function factory() {
1328 static $instance = false;
1329
1330 if ( ! $instance ) {
1331 $instance = new self();
1332 $instance->setup();
1333 }
1334
1335 return $instance;
1336 }
1337 }
1338