PluginProbe
ElasticPress / 4.6.0
ElasticPress v4.6.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.6.0, at includes/classes/IndexHelper.php

1,392 lines 41.1 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://elasticpress.zendesk.com/hc/en-us/articles/16672117103501-Sync-Process
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
691 $wp_error_messages = $return->get_error_messages();
692
693 $this->maybe_process_error_limit(
694 count( $this->index_meta['current_sync_item']['errors'] ) + count( $wp_error_messages ),
695 count( $this->index_meta['current_sync_item']['errors'] ),
696 $wp_error_messages
697 );
698
699 $this->output( implode( "\n", $wp_error_messages ), 'warning' );
700 } elseif ( count( $failed_objects ) ) {
701 $errors_output = $this->output_index_errors( $failed_objects );
702
703 $this->index_meta['current_sync_item']['synced'] += count( $queued_items ) - count( $failed_objects );
704
705 $this->maybe_process_error_limit(
706 $this->index_meta['current_sync_item']['failed'] + count( $failed_objects ),
707 $this->index_meta['current_sync_item']['failed'],
708 $errors_output
709 );
710
711 $this->index_meta['current_sync_item']['failed'] += count( $failed_objects );
712
713 $this->output( $errors_output, 'warning' );
714 } else {
715 $this->index_meta['current_sync_item']['synced'] += count( $queued_items );
716 }
717 }
718
719 $this->index_meta['current_sync_item']['last_processed_object_id'] = end( $this->current_query['objects'] )->ID;
720
721 $this->output(
722 sprintf(
723 /* translators: 1. Indexable type 2. Offset start, 3. Offset end, 4. Found items 5. Last object ID */
724 esc_html__( 'Processed %1$s %2$d - %3$d of %4$d. Last Object ID: %5$d', 'elasticpress' ),
725 esc_html( strtolower( $indexable->labels['plural'] ) ),
726 $this->index_meta['from'],
727 $this->index_meta['offset'],
728 $this->index_meta['found_items'],
729 $this->index_meta['current_sync_item']['last_processed_object_id']
730 ),
731 'info',
732 'index_next_batch'
733 );
734 }
735
736 /**
737 * If the number of errors is greater than the limit, slice the array to the limit.
738 * If the number of errors is less than or equal the limit, add the error message to the array (if it's not there).
739 * Merges the new errors with the existing errors.
740 *
741 * @since 4.5.1
742 * @param int $count Number of errors.
743 * @param int $num Number of errors to subtract from $limit.
744 * @param array $errors Array of errors.
745 */
746 protected function maybe_process_error_limit( $count, $num, $errors ) {
747 $error_store_msg = __( 'Reached maximum number of errors to store', 'elasticpress' );
748
749 /**
750 * Filter the number of errors of a current sync that should be stored.
751 *
752 * @since 4.5.1
753 * @hook ep_current_sync_number_of_errors_stored
754 * @param {int} $number Number of errors to be logged.
755 * @return {int} New value
756 */
757 $limit = (int) apply_filters( 'ep_current_sync_number_of_errors_stored', 50 );
758
759 if ( $limit > 0 && $count > $limit ) {
760 $diff = $limit - $num;
761 if ( $diff > 0 ) {
762 $errors = array_slice( $errors, 0, $diff );
763 } else {
764 $errors = [];
765 if ( end( $this->index_meta['current_sync_item']['errors'] ) !== $error_store_msg ) {
766 $this->index_meta['current_sync_item']['errors'][] = $error_store_msg;
767 }
768 }
769 }
770
771 $this->index_meta['current_sync_item']['errors'] = array_merge( $this->index_meta['current_sync_item']['errors'], $errors );
772 }
773
774 /**
775 * Update the sync info with the totals from the last sync item.
776 *
777 * @since 4.2.0
778 */
779 protected function update_totals_from_current_sync_item() {
780 $current_sync_item = $this->index_meta['current_sync_item'];
781
782 $errors = array_merge(
783 $this->index_meta['totals']['errors'],
784 $current_sync_item['errors']
785 );
786
787 /**
788 * Filter the number of errors of a sync that should be stored.
789 *
790 * @since 4.2.0
791 * @hook ep_sync_number_of_errors_stored
792 * @param {int} $number Number of errors to be logged.
793 * @return {int} New value
794 */
795 $logged_errors = (int) apply_filters( 'ep_sync_number_of_errors_stored', 50 );
796
797 $this->index_meta['totals']['total'] += $current_sync_item['total'];
798 $this->index_meta['totals']['synced'] += $current_sync_item['synced'];
799 $this->index_meta['totals']['skipped'] += $current_sync_item['skipped'];
800 $this->index_meta['totals']['failed'] += $current_sync_item['failed'];
801 $this->index_meta['totals']['errors'] = array_slice( $errors, $logged_errors * -1 );
802 }
803
804 /**
805 * Make the necessary clean up after a sync item of the stack was completely done.
806 *
807 * @since 4.0.0
808 * @return void
809 */
810 protected function index_cleanup() {
811 wp_reset_postdata();
812
813 $this->update_totals_from_current_sync_item();
814
815 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
816
817 $current_sync_item = $this->index_meta['current_sync_item'];
818
819 $this->index_meta['current_sync_item'] = null;
820
821 if ( $current_sync_item['failed'] ) {
822 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
823 $message = sprintf(
824 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of failed objects */
825 esc_html__( 'Number of %1$s index errors on site %2$d: %3$d', 'elasticpress' ),
826 esc_html( strtolower( $indexable->labels['plural'] ) ),
827 $current_sync_item['blog_id'],
828 $current_sync_item['failed']
829 );
830 } else {
831 $message = sprintf(
832 /* translators: 1: indexable (plural), 2: number of failed objects */
833 esc_html__( 'Number of %1$s index errors: %2$d', 'elasticpress' ),
834 esc_html( strtolower( $indexable->labels['plural'] ) ),
835 $current_sync_item['failed']
836 );
837 }
838
839 $this->output( $message, 'warning' );
840 }
841
842 $this->index_meta['offset'] = 0;
843
844 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
845 $message = sprintf(
846 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of synced objects */
847 esc_html__( 'Number of %1$s indexed on site %2$d: %3$d', 'elasticpress' ),
848 esc_html( strtolower( $indexable->labels['plural'] ) ),
849 $current_sync_item['blog_id'],
850 $current_sync_item['synced']
851 );
852 } else {
853 $message = sprintf(
854 /* translators: 1: indexable (plural), 2: number of synced objects */
855 esc_html__( 'Number of %1$s indexed: %2$d', 'elasticpress' ),
856 esc_html( strtolower( $indexable->labels['plural'] ) ),
857 $current_sync_item['synced']
858 );
859 }
860
861 $this->output_success( $message );
862 }
863
864 /**
865 * Update last sync info.
866 *
867 * @since 4.2.0
868 */
869 protected function update_last_index() {
870 $start_time = $this->index_meta['start_time'];
871 $totals = $this->index_meta['totals'];
872 $method = $this->index_meta['method'];
873 $is_full_sync = $this->index_meta['put_mapping'];
874
875 $this->index_meta = null;
876
877 $end_date_time = date_create( 'now', wp_timezone() );
878 $start_time_sec = (int) $start_time;
879
880 $totals['end_date_time'] = $end_date_time ? $end_date_time->format( DATE_ATOM ) : false;
881 $totals['start_date_time'] = $start_time ? wp_date( DATE_ATOM, $start_time_sec ) : false;
882 $totals['end_time_gmt'] = time();
883 $totals['total_time'] = microtime( true ) - $start_time;
884 $totals['method'] = $method;
885 $totals['is_full_sync'] = $is_full_sync;
886 Utils\update_option( 'ep_last_cli_index', $totals, false );
887 Utils\update_option( 'ep_last_index', $totals, false );
888 }
889
890 /**
891 * Make the necessary clean up after everything was sync'd.
892 *
893 * @since 4.0.0
894 */
895 protected function full_index_complete() {
896 $this->update_last_index();
897
898 /**
899 * Fires after executing a reindex
900 *
901 * @since 4.0.0
902 * @hook ep_after_sync_index
903 */
904 do_action( 'ep_after_sync_index' );
905
906 /**
907 * Fires after executing a reindex
908 *
909 * @since 3.5.5 Previously called only as 'ep_after_dashboard_index'
910 * @since 4.0.0 Made available for all methods
911 * @hook ep_after_{$index_method}_index
912 */
913 do_action( "ep_after_{$this->args['method']}_index" );
914
915 $this->output_success( esc_html__( 'Sync complete', 'elasticpress' ) );
916 }
917
918 /**
919 * Check if network aliases need to be created.
920 *
921 * @since 4.0.0
922 * @return boolean
923 */
924 protected function has_network_alias_to_be_created() {
925 return count( $this->index_meta['network_alias'] ) > 0;
926 }
927
928 /**
929 * Create the next network alias.
930 *
931 * @since 4.0.0
932 */
933 protected function create_network_alias() {
934 $indexes = [];
935 $indexable = Indexables::factory()->get( array_shift( $this->index_meta['network_alias'] ) );
936
937 $sites = Utils\get_sites();
938
939 foreach ( $sites as $site ) {
940
941 if ( ! Utils\is_site_indexable( $site['blog_id'] ) ) {
942 continue;
943 }
944
945 switch_to_blog( $site['blog_id'] );
946 $indexes[] = $indexable->get_index_name();
947 restore_current_blog();
948 }
949
950 $result = $indexable->create_network_alias( $indexes );
951
952 if ( $result ) {
953 $this->output_success(
954 sprintf(
955 /* translators: 1: Indexable name */
956 esc_html__( 'Network alias created for %1$s', 'elasticpress' ),
957 esc_html( strtolower( $indexable->labels['plural'] ) )
958 )
959 );
960 } else {
961 $this->output_error(
962 sprintf(
963 /* translators: 1: Indexable name */
964 esc_html__( 'Network alias creation failed for %1$s', 'elasticpress' ),
965 esc_html( strtolower( $indexable->labels['plural'] ) )
966 )
967 );
968 }
969 }
970
971 /**
972 * Output a message.
973 *
974 * @since 4.0.0
975 * @param string|array $message_text Message to be outputted
976 * @param string $type Type of message
977 * @param string $context Context of the output
978 * @return void
979 */
980 protected function output( $message_text, $type = 'info', $context = '' ) {
981 if ( $this->index_meta ) {
982 Utils\update_option( 'ep_index_meta', $this->index_meta );
983 } else {
984 Utils\delete_option( 'ep_index_meta' );
985 $totals = $this->get_last_index();
986 }
987
988 $message = [
989 'message' => ( is_array( $message_text ) ) ? implode( "\n", $message_text ) : $message_text,
990 'index_meta' => $this->index_meta,
991 'totals' => $totals ?? [],
992 'status' => $type,
993 ];
994
995 if ( is_callable( $this->args['output_method'] ) ) {
996 call_user_func( $this->args['output_method'], $message, $this->args, $this->index_meta, $context );
997 }
998 }
999
1000 /**
1001 * Wrapper to the `output` method with a success message.
1002 *
1003 * @since 4.0.0
1004 * @param string $message Message string.
1005 * @param string $context Context of the output.
1006 */
1007 protected function output_success( $message, $context = '' ) {
1008 $this->output( $message, 'success', $context );
1009 }
1010
1011 /**
1012 * Wrapper to the `output` method with an error message.
1013 *
1014 * @since 4.0.0
1015 * @param string $message Message string.
1016 * @param string $context Context of the output.
1017 */
1018 protected function output_error( $message, $context = '' ) {
1019 $this->output( $message, 'error', $context );
1020 }
1021
1022 /**
1023 * Output index errors of failed objects.
1024 *
1025 * @since 4.0.0
1026 * @param array $failed_objects Failed objects
1027 */
1028 protected function output_index_errors( $failed_objects ) {
1029 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
1030
1031 $error_text = [];
1032
1033 foreach ( $failed_objects as $object ) {
1034 $error_text[] = ! empty( $object['index'] ) ? $object['index']['_id'] . ' (' . $indexable->labels['singular'] . '): [' . $object['index']['error']['type'] . '] ' . $object['index']['error']['reason'] : (string) $object;
1035 }
1036
1037 return $error_text;
1038 }
1039
1040 /**
1041 * Utilitary function to check if the indexable is being fully reindexed, i.e.,
1042 * the index was deleted, a new mapping was sent and content is being reindexed.
1043 *
1044 * @param string $indexable_slug Indexable slug.
1045 * @param int|null $blog_id Blog ID
1046 * @return boolean
1047 */
1048 public function is_full_reindexing( $indexable_slug, $blog_id = null ) {
1049 if ( empty( $this->index_meta ) || empty( $this->index_meta['put_mapping'] ) ) {
1050 /**
1051 * Filter if a fully reindex is being done to an indexable
1052 *
1053 * @since 4.0.0
1054 * @hook ep_is_full_reindexing_{$indexable_slug}
1055 * @param {bool} $is_full_reindexing If is fully reindexing
1056 * @return {bool} New value
1057 */
1058 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", false );
1059 }
1060
1061 $sync_stack = ( ! empty( $this->index_meta['sync_stack'] ) ) ? $this->index_meta['sync_stack'] : [];
1062 $current_sync_item = ( ! empty( $this->index_meta['current_sync_item'] ) ) ? $this->index_meta['current_sync_item'] : [];
1063
1064 $is_full_reindexing = false;
1065
1066 $all_items = $sync_stack;
1067 if ( ! empty( $current_sync_item ) ) {
1068 $all_items += [ $current_sync_item ];
1069 }
1070
1071 foreach ( $all_items as $sync_item ) {
1072 if ( $sync_item['indexable'] !== $indexable_slug ) {
1073 continue;
1074 }
1075
1076 if (
1077 ( empty( $sync_item['blog_id'] ) && ! $blog_id ) ||
1078 (int) $sync_item['blog_id'] === $blog_id
1079 ) {
1080 $is_full_reindexing = true;
1081 }
1082 }
1083
1084 /* this filter is documented above */
1085 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", $is_full_reindexing );
1086 }
1087
1088 /**
1089 * Get the last index/sync meta information.
1090 *
1091 * @since 4.2.0
1092 * @return array
1093 */
1094 public function get_last_index() {
1095 return Utils\get_option( 'ep_last_index', [] );
1096 }
1097
1098 /**
1099 * Check if an object should be indexed or skipped.
1100 *
1101 * We used to have two different filters for this (one for the dashboard, another for CLI),
1102 * this method combines both.
1103 *
1104 * @param {stdClass} $object Object to be checked
1105 * @param {Indexable} $indexable Indexable
1106 * @return boolean
1107 */
1108 protected function should_skip_object_index( $object, $indexable ) {
1109 /**
1110 * Filter whether to not sync specific item in dashboard or not
1111 *
1112 * @since 2.1
1113 * @hook ep_item_sync_kill
1114 * @param {boolean} $kill False means dont sync
1115 * @param {array} $object Object to sync
1116 * @return {Indexable} Indexable that object belongs to
1117 */
1118 $ep_item_sync_kill = apply_filters( 'ep_item_sync_kill', false, $object, $indexable );
1119
1120 /**
1121 * Conditionally kill indexing for a post
1122 *
1123 * @hook ep_{indexable_slug}_index_kill
1124 * @param {bool} $index True means dont index
1125 * @param {int} $object_id Object ID
1126 * @return {bool} New value
1127 */
1128 $ep_indexable_sync_kill = apply_filters( 'ep_' . $indexable->slug . '_index_kill', false, $object->ID );
1129
1130 return $ep_item_sync_kill || $ep_indexable_sync_kill;
1131 }
1132
1133 /**
1134 * Given an array, create a new sync item and add it to the stack.
1135 *
1136 * @since 4.5.0
1137 * @param array $sync_stack_item The new sync item
1138 */
1139 protected function add_sync_item_to_stack( array $sync_stack_item ) {
1140 $indexable_slug = $sync_stack_item['indexable'];
1141 $indexable_object = Indexables::factory()->get( $indexable_slug );
1142
1143 if ( ! $indexable_object ) {
1144 return;
1145 }
1146
1147 $index_exists = in_array( $indexable_object->get_index_name(), $this->index_meta['starting_indices'], true );
1148
1149 $sync_stack_item['put_mapping'] = ! empty( $this->args['put_mapping'] ) || ! $index_exists;
1150
1151 if ( ! Indexables::factory()->is_active( $indexable_slug ) ) {
1152 array_unshift( $this->index_meta['sync_stack'], $sync_stack_item );
1153 return;
1154 }
1155
1156 // This is needed, because get_objects_to_index() calculates its total based on the current sync item.
1157 $this->index_meta['current_sync_item'] = $sync_stack_item;
1158
1159 $objects_to_index = $this->get_objects_to_index();
1160
1161 $sync_stack_item['found_items'] = $objects_to_index['total_objects'] ?? 0;
1162
1163 $this->index_meta['sync_stack'][] = $sync_stack_item;
1164 }
1165
1166 /**
1167 * Processes an indexable that is not active.
1168 *
1169 * If running a full sync, delete the index of an unused indexable.
1170 *
1171 * @since 4.5.0
1172 */
1173 protected function process_not_active_indexable_sync_item() {
1174 $current_sync_item = $this->index_meta['current_sync_item'];
1175
1176 $this->index_meta['current_sync_item'] = null;
1177
1178 if ( empty( $current_sync_item['put_mapping'] ) ) {
1179 return;
1180 }
1181
1182 $indexable = Indexables::factory()->get( $current_sync_item['indexable'] );
1183
1184 if ( ! in_array( $indexable->get_index_name(), $this->index_meta['starting_indices'], true ) ) {
1185 return;
1186 }
1187
1188 $indexable->delete_index();
1189
1190 $this->output_success(
1191 sprintf(
1192 /* translators: Index name */
1193 esc_html__( 'Index %s deleted', 'elasticpress' ),
1194 $indexable->get_index_name()
1195 )
1196 );
1197 }
1198
1199 /**
1200 * Resets some values to reduce memory footprint.
1201 */
1202 protected function stop_the_insanity() {
1203 global $wpdb, $wp_object_cache, $wp_actions;
1204
1205 $wpdb->queries = [];
1206
1207 /*
1208 * Runtime flushing was introduced in WordPress 6.0 and will flush only the
1209 * in-memory cache for persistent object caches
1210 */
1211 if ( function_exists( 'wp_cache_flush_runtime' ) ) {
1212 wp_cache_flush_runtime();
1213 } else {
1214 /*
1215 * In the case where we're not using an external object cache, we need to call flush on the default
1216 * WordPress object cache class to clear the values from the cache property
1217 */
1218 if ( ! wp_using_ext_object_cache() ) {
1219 wp_cache_flush();
1220 }
1221 }
1222
1223 if ( is_object( $wp_object_cache ) ) {
1224 $wp_object_cache->group_ops = [];
1225 $wp_object_cache->stats = [];
1226 $wp_object_cache->memcache_debug = [];
1227
1228 // Make sure this is a public property, before trying to clear it.
1229 try {
1230 $cache_property = new \ReflectionProperty( $wp_object_cache, 'cache' );
1231 if ( $cache_property->isPublic() ) {
1232 $wp_object_cache->cache = [];
1233 }
1234 unset( $cache_property );
1235 } catch ( \ReflectionException $e ) {
1236 // No need to catch.
1237 }
1238
1239 if ( is_callable( $wp_object_cache, '__remoteset' ) ) {
1240 call_user_func( [ $wp_object_cache, '__remoteset' ] );
1241 }
1242 }
1243
1244 // Prevent wp_actions from growing out of control.
1245 // phpcs:disable
1246 $wp_actions = $this->temporary_wp_actions;
1247 // phpcs:enable
1248
1249 // It's high memory consuming as WP_Query instance holds all query results inside itself
1250 // and in theory $wp_filter will not stop growing until Out Of Memory exception occurs.
1251 remove_filter( 'get_term_metadata', [ wp_metadata_lazyloader(), 'lazyload_term_meta' ] );
1252
1253 /**
1254 * Fires after reducing the memory footprint
1255 *
1256 * @since 4.3.0
1257 * @hook ep_stop_the_insanity
1258 */
1259 do_action( 'ep_stop_the_insanity' );
1260 }
1261
1262 /**
1263 * Utilitary function to delete the index meta option.
1264 *
1265 * @since 4.0.0
1266 */
1267 public function clear_index_meta() {
1268 $this->index_meta = false;
1269 Utils\delete_option( 'ep_index_meta', false );
1270 }
1271
1272 /**
1273 * Utilitary function to get the index meta option.
1274 *
1275 * @return array
1276 * @since 4.0.0
1277 */
1278 public function get_index_meta() {
1279 return Utils\get_option( 'ep_index_meta', [] );
1280 }
1281
1282 /**
1283 * Handle fatal errors during syncs.
1284 *
1285 * Added by register_shutdown_function. It will not be called if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1286 *
1287 * @since 4.2.0
1288 */
1289 public function handle_index_error() {
1290 $error = error_get_last();
1291 if ( empty( $error['type'] ) || E_ERROR !== $error['type'] ) {
1292 return;
1293 }
1294
1295 $this->on_error_update_and_clean( $error );
1296 }
1297
1298 /**
1299 * Handle fatal errors during syncs.
1300 *
1301 * Added via the `wp_php_error_message` filter. It will be called only if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1302 *
1303 * @since 4.2.0
1304 * @param bool $message HTML error message to display.
1305 * @param array $error Error information retrieved from error_get_last().
1306 * @return bool
1307 */
1308 public function wp_handle_index_error( $message, $error ) {
1309 $this->on_error_update_and_clean( $error );
1310 return $message;
1311 }
1312
1313 /**
1314 * Logs the error and clears the sync status, preventing the sync status from being stuck.
1315 *
1316 * @since 4.2.0
1317 * @param array $error Error information retrieved from error_get_last().
1318 * @param string $context Context of the error.
1319 */
1320 protected function on_error_update_and_clean( $error, $context = 'sync' ) {
1321 $this->update_totals_from_current_sync_item();
1322
1323 $totals = $this->index_meta['totals'];
1324
1325 $this->index_meta['totals']['errors'][] = $error['message'];
1326 $this->index_meta['totals']['failed'] = $totals['total'] - ( $totals['synced'] + $totals['skipped'] );
1327 $this->update_last_index();
1328
1329 /**
1330 * Fires after a sync failed due to a PHP fatal error.
1331 *
1332 * @since 4.2.0
1333 * @hook ep_after_sync_error
1334 * @param {array} $error The error
1335 */
1336 do_action( 'ep_after_sync_error', $error );
1337
1338 switch ( $context ) {
1339 case 'mapping':
1340 $message = sprintf(
1341 /* translators: Error message */
1342 esc_html__( 'Mapping failed: %s', 'elasticpress' ),
1343 Utils\get_elasticsearch_error_reason( $error['message'] )
1344 );
1345 $message .= "\n";
1346 $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' );
1347 break;
1348 default:
1349 /* translators: Error message */
1350 $message = sprintf( esc_html__( 'Index failed: %s', 'elasticpress' ), $error['message'] );
1351 break;
1352 }
1353
1354 $this->output_error( $message );
1355 }
1356
1357 /**
1358 * Return the default number of documents to be sent to Elasticsearch on each batch.
1359 *
1360 * @since 4.4.0
1361 * @return integer
1362 */
1363 public function get_index_default_per_page() : int {
1364 /**
1365 * Filter number of items to index per cycle in the dashboard
1366 *
1367 * @since 2.1
1368 * @hook ep_index_default_per_page
1369 * @param {int} Entries per cycle
1370 * @return {int} New number of entries
1371 */
1372 return (int) apply_filters( 'ep_index_default_per_page', Utils\get_option( 'ep_bulk_setting', 350 ) );
1373 }
1374
1375 /**
1376 * Return singleton instance of class.
1377 *
1378 * @return self
1379 * @since 4.0.0
1380 */
1381 public static function factory() {
1382 static $instance = false;
1383
1384 if ( ! $instance ) {
1385 $instance = new self();
1386 $instance->setup();
1387 }
1388
1389 return $instance;
1390 }
1391 }
1392