PluginProbe
ElasticPress / 4.4.1
ElasticPress v4.4.1
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.4.1, at includes/classes/IndexHelper.php

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