PluginProbe
ElasticPress / 4.3.1
ElasticPress v4.3.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.3.1, at includes/classes/IndexHelper.php

1,239 lines 36.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 http://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();
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 ( $result ) {
385 $this->output_success( esc_html__( 'Mapping sent', 'elasticpress' ) );
386 } else {
387 $this->output_error( esc_html__( 'Mapping failed', 'elasticpress' ) );
388 }
389 }
390
391 /**
392 * Index documents of an index.
393 *
394 * @since 4.0.0
395 */
396 protected function index_objects() {
397 global $wp_actions;
398 // Hold original wp_actions.
399 $this->temporary_wp_actions = $wp_actions;
400
401 $this->current_query = $this->get_objects_to_index();
402
403 $this->index_meta['from'] = $this->index_meta['offset'];
404 $this->index_meta['found_items'] = (int) $this->current_query['total_objects'];
405 $this->index_meta['current_sync_item']['total'] = $this->index_meta['found_items'];
406
407 if ( 'offset' === $this->index_meta['pagination_method'] ) {
408 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
409
410 if ( empty( $this->index_meta['current_sync_item']['shown_skip_message'] ) ) {
411 $this->index_meta['current_sync_item']['shown_skip_message'] = true;
412
413 $this->output(
414 sprintf(
415 /* translators: 1. Number of objects skipped 2. Indexable type */
416 esc_html__( 'Skipping %1$d %2$s…', 'elasticpress' ),
417 $this->index_meta['from'],
418 esc_html( strtolower( $indexable->labels['plural'] ) )
419 ),
420 'info',
421 'index_objects'
422 );
423 }
424 }
425
426 if ( $this->index_meta['found_items'] && $this->index_meta['offset'] < $this->index_meta['found_items'] ) {
427 $this->index_next_batch();
428 } else {
429 $this->index_cleanup();
430 }
431
432 usleep( 500 );
433
434 // Avoid running out of memory.
435 $this->stop_the_insanity();
436 }
437
438 /**
439 * Query the next objects to be indexed.
440 *
441 * @since 4.0.0
442 * @return array
443 */
444 protected function get_objects_to_index() {
445 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
446
447 /**
448 * Fires right before entries are about to be indexed.
449 *
450 * @since 4.0.0
451 *
452 * @hook ep_pre_sync_index
453 * @param {array} $args Args to query content with
454 */
455 do_action( 'ep_pre_sync_index', $this->index_meta, ( $this->index_meta['start'] ? 'start' : false ), $indexable );
456
457 /**
458 * Fires right before entries are about to be indexed.
459 *
460 * @since 2.1 Previously called only as 'ep_pre_dashboard_index'
461 * @since 4.0.0 Made available for all methods
462 *
463 * @hook ep_pre_{$index_method}_index
464 * @param {array} $args Args to query content with
465 */
466 do_action( "ep_pre_{$this->args['method']}_index", $this->index_meta, ( $this->index_meta['start'] ? 'start' : false ), $indexable );
467
468 /**
469 * Filter number of items to index per cycle in the dashboard
470 *
471 * @since 2.1
472 * @hook ep_index_default_per_page
473 * @param {int} Entries per cycle
474 * @return {int} New number of entries
475 */
476 $per_page = apply_filters( 'ep_index_default_per_page', Utils\get_option( 'ep_bulk_setting', 350 ) );
477
478 if ( ! empty( $this->args['per_page'] ) ) {
479 $per_page = $this->args['per_page'];
480 }
481
482 if ( ! empty( $this->args['nobulk'] ) ) {
483 $per_page = 1;
484 }
485
486 $args = [
487 'per_page' => absint( $per_page ),
488 ];
489
490 if ( ! $indexable->support_indexing_advanced_pagination || 'offset' === $this->index_meta['pagination_method'] ) {
491 $args['offset'] = $this->index_meta['offset'];
492 }
493
494 if ( ! empty( $this->args['post-ids'] ) ) {
495 $args['include'] = $this->args['post-ids'];
496 }
497
498 if ( ! empty( $this->args['include'] ) ) {
499 $include = ( is_array( $this->args['include'] ) ) ? $this->args['include'] : explode( ',', str_replace( ' ', '', $this->args['include'] ) );
500 $args['include'] = array_map( 'absint', $include );
501 $args['per_page'] = count( $args['include'] );
502 }
503
504 if ( ! empty( $this->args['post_type'] ) ) {
505 $args['post_type'] = ( is_array( $this->args['post_type'] ) ) ? $this->args['post_type'] : explode( ',', $this->args['post_type'] );
506 $args['post_type'] = array_map( 'trim', $args['post_type'] );
507 }
508
509 // Start of advanced pagination arguments.
510 if ( ! empty( $this->args['upper_limit_object_id'] ) && is_numeric( $this->args['upper_limit_object_id'] ) ) {
511 $args['ep_indexing_upper_limit_object_id'] = $this->args['upper_limit_object_id'];
512 }
513
514 if ( ! empty( $this->args['lower_limit_object_id'] ) && is_numeric( $this->args['lower_limit_object_id'] ) ) {
515 $args['ep_indexing_lower_limit_object_id'] = $this->args['lower_limit_object_id'];
516 }
517
518 if ( ! empty( $this->index_meta['current_sync_item']['last_processed_object_id'] ) &&
519 is_numeric( $this->index_meta['current_sync_item']['last_processed_object_id'] )
520 ) {
521 $args['ep_indexing_last_processed_object_id'] = $this->index_meta['current_sync_item']['last_processed_object_id'];
522 }
523 // End of advanced pagination arguments.
524
525 /**
526 * Filters arguments used to query for content for each indexable
527 *
528 * @since 4.0.0
529 *
530 * @hook ep_sync_index_args
531 * @param {array} $args Args to query content with
532 * @return {array} New query args
533 */
534 $args = apply_filters( 'ep_sync_index_args', $args );
535
536 /**
537 * Filters arguments used to query for content for each indexable
538 *
539 * @since 3.0 Previously called only as 'ep_dashboard_index_args'
540 *
541 * @hook ep_{$index_method}_index_args
542 * @param {array} $args Args to query content with
543 * @return {array} New query args
544 */
545 $args = apply_filters( "ep_{$this->args['method']}_index_args", $args );
546
547 return $indexable->query_db( $args );
548 }
549
550 /**
551 * Index the next batch of documents.
552 *
553 * @since 4.0.0
554 */
555 protected function index_next_batch() {
556 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
557
558 /**
559 * Fires right before entries are about to be indexed in a dashboard sync
560 *
561 * @since 4.0.0
562 * @hook ep_pre_index_batch
563 * @param {array} $index_meta Index meta
564 */
565 do_action( 'ep_pre_index_batch', $this->index_meta );
566
567 $queued_items = [];
568
569 foreach ( $this->current_query['objects'] as $object ) {
570 if ( $this->should_skip_object_index( $object, $indexable ) ) {
571 $this->index_meta['current_sync_item']['skipped']++;
572 } else {
573 $queued_items[ $object->ID ] = true;
574 }
575 }
576
577 $this->index_meta['offset'] = absint( $this->index_meta['offset'] + count( $this->current_query['objects'] ) );
578
579 if ( ! empty( $queued_items ) ) {
580 $total_attempts = ( ! empty( $this->args['total_attempts'] ) ) ? absint( $this->args['total_attempts'] ) : 1;
581 $queued_items_ids = array_keys( $queued_items );
582
583 /**
584 * Filters the number of times the index will try before failing.
585 *
586 * @since 3.0
587 * @hook ep_index_batch_attempts_number
588 * @param {int} $total_attempts Number of attempts
589 * @return {int} New number of attempts
590 */
591 $total_attempts = apply_filters( 'ep_index_batch_attempts_number', $total_attempts );
592
593 for ( $attempts = 1; $attempts <= $total_attempts; $attempts++ ) {
594 $nobulk = ! empty( $this->args['nobulk'] );
595 $failed_objects = [];
596
597 /**
598 * Fires before each attempt of indexing objects
599 *
600 * @hook ep_index_batch_new_attempt
601 * @param {int} $attempts Current attempt
602 * @param {int} $total_attempts Total number of attempts
603 */
604 do_action( 'ep_index_batch_new_attempt', $attempts, $total_attempts );
605
606 $should_retry = false;
607
608 if ( $nobulk ) {
609 $object_id = reset( $queued_items_ids );
610 $return = $indexable->index( $object_id, true );
611
612 /**
613 * Fires after one by one indexing an object
614 *
615 * @since 4.0.0
616 *
617 * @hook ep_sync_object_index
618 * @param {int} $object_id Object to index
619 * @param {Indexable} $indexable Current indexable
620 * @param {mixed} $return Return of the index() call
621 */
622 do_action( 'ep_sync_object_index', $object_id, $indexable, $return );
623
624 /**
625 * Fires after one by one indexing an object
626 *
627 * @since 3.0 Previously called only as 'ep_cli_object_index'
628 * @since 4.0.0 Made available for all methods
629 *
630 * @hook ep_{$index_method}_object_index
631 * @param {int} $object_id Object to index
632 * @param {Indexable} $indexable Current indexable
633 * @param {mixed} $return Return of the index() call
634 */
635 do_action( "ep_{$this->args['method']}_object_index", $object_id, $indexable, $return );
636
637 if ( is_object( $return ) && ! empty( $return->error ) ) {
638 if ( ! empty( $return->error->reason ) ) {
639 $failed_objects[ $object->ID ] = (array) $return->error;
640 } else {
641 $failed_objects[ $object->ID ] = null;
642 }
643 }
644
645 if ( is_wp_error( $return ) ) {
646 $should_retry = true;
647 }
648 } else {
649 if ( ! empty( $this->args['static_bulk'] ) ) {
650 $bulk_requests = [ $indexable->bulk_index( $queued_items_ids ) ];
651 } else {
652 $bulk_requests = $indexable->bulk_index_dynamically( $queued_items_ids );
653 }
654
655 $failed_objects = [];
656 foreach ( $bulk_requests as $return ) {
657 /**
658 * Fires after bulk indexing
659 *
660 * @hook ep_cli_{indexable_slug}_bulk_index
661 * @param {array} $objects Objects being indexed
662 * @param {array} response Elasticsearch bulk index response
663 */
664 do_action( "ep_cli_{$indexable->slug}_bulk_index", $queued_items, $return );
665
666 if ( is_wp_error( $return ) ) {
667 $should_retry = true;
668 }
669 if ( is_array( $return ) && isset( $return['errors'] ) && true === $return['errors'] ) {
670 $failed_objects = array_merge(
671 $failed_objects,
672 array_filter(
673 $return['items'],
674 function( $item ) {
675 return ! empty( $item['index']['error'] );
676 }
677 )
678 );
679 }
680 }
681 }
682
683 // Things worked, we don't need to try again.
684 if ( ! $should_retry && ! count( $failed_objects ) ) {
685 break;
686 }
687 }
688
689 if ( is_wp_error( $return ) ) {
690 $this->index_meta['current_sync_item']['failed'] += count( $queued_items );
691 $this->index_meta['current_sync_item']['errors'] = array_merge( $this->index_meta['current_sync_item']['errors'], $return->get_error_messages() );
692
693 $this->output( implode( "\n", $return->get_error_messages() ), 'warning' );
694 } elseif ( count( $failed_objects ) ) {
695 $errors_output = $this->output_index_errors( $failed_objects );
696
697 $this->index_meta['current_sync_item']['synced'] += count( $queued_items ) - count( $failed_objects );
698 $this->index_meta['current_sync_item']['failed'] += count( $failed_objects );
699 $this->index_meta['current_sync_item']['errors'] = array_merge( $this->index_meta['current_sync_item']['errors'], $errors_output );
700
701 $this->output( $errors_output, 'warning' );
702 } else {
703 $this->index_meta['current_sync_item']['synced'] += count( $queued_items );
704 }
705 }
706
707 $this->index_meta['current_sync_item']['last_processed_object_id'] = end( $this->current_query['objects'] )->ID;
708
709 $this->output(
710 sprintf(
711 /* translators: 1. Indexable type 2. Offset start, 3. Offset end, 4. Found items 5. Last object ID */
712 esc_html__( 'Processed %1$s %2$d - %3$d of %4$d. Last Object ID: %5$d', 'elasticpress' ),
713 esc_html( strtolower( $indexable->labels['plural'] ) ),
714 $this->index_meta['from'],
715 $this->index_meta['offset'],
716 $this->index_meta['found_items'],
717 $this->index_meta['current_sync_item']['last_processed_object_id']
718 ),
719 'info',
720 'index_next_batch'
721 );
722 }
723
724 /**
725 * Update the sync info with the totals from the last sync item.
726 *
727 * @since 4.2.0
728 */
729 protected function update_totals_from_current_sync_item() {
730 $current_sync_item = $this->index_meta['current_sync_item'];
731
732 $errors = array_merge(
733 $this->index_meta['totals']['errors'],
734 $current_sync_item['errors']
735 );
736
737 /**
738 * Filter the number of errors of a sync that should be stored.
739 *
740 * @since 4.2.0
741 * @hook ep_sync_number_of_errors_stored
742 * @param {int} $number Number of errors to be logged.
743 * @return {int} New value
744 */
745 $logged_errors = (int) apply_filters( 'ep_sync_number_of_errors_stored', 50 );
746
747 $this->index_meta['totals']['total'] += $current_sync_item['total'];
748 $this->index_meta['totals']['synced'] += $current_sync_item['synced'];
749 $this->index_meta['totals']['skipped'] += $current_sync_item['skipped'];
750 $this->index_meta['totals']['failed'] += $current_sync_item['failed'];
751 $this->index_meta['totals']['errors'] = array_slice( $errors, $logged_errors * -1 );
752 }
753
754 /**
755 * Make the necessary clean up after a sync item of the stack was completely done.
756 *
757 * @since 4.0.0
758 * @return void
759 */
760 protected function index_cleanup() {
761 wp_reset_postdata();
762
763 $this->update_totals_from_current_sync_item();
764
765 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
766
767 $current_sync_item = $this->index_meta['current_sync_item'];
768
769 $this->index_meta['current_sync_item'] = null;
770
771 if ( $current_sync_item['failed'] ) {
772 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
773 $message = sprintf(
774 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of failed objects */
775 esc_html__( 'Number of %1$s index errors on site %2$d: %3$d', 'elasticpress' ),
776 esc_html( strtolower( $indexable->labels['plural'] ) ),
777 $current_sync_item['blog_id'],
778 $current_sync_item['failed']
779 );
780 } else {
781 $message = sprintf(
782 /* translators: 1: indexable (plural), 2: number of failed objects */
783 esc_html__( 'Number of %1$s index errors: %2$d', 'elasticpress' ),
784 esc_html( strtolower( $indexable->labels['plural'] ) ),
785 $current_sync_item['failed']
786 );
787 }
788
789 $this->output( $message, 'warning' );
790 }
791
792 $this->index_meta['offset'] = 0;
793
794 if ( ! empty( $current_sync_item['blog_id'] ) && defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
795 $message = sprintf(
796 /* translators: 1: indexable (plural), 2: Blog ID, 3: number of synced objects */
797 esc_html__( 'Number of %1$s indexed on site %2$d: %3$d', 'elasticpress' ),
798 esc_html( strtolower( $indexable->labels['plural'] ) ),
799 $current_sync_item['blog_id'],
800 $current_sync_item['synced']
801 );
802 } else {
803 $message = sprintf(
804 /* translators: 1: indexable (plural), 2: number of synced objects */
805 esc_html__( 'Number of %1$s indexed: %2$d', 'elasticpress' ),
806 esc_html( strtolower( $indexable->labels['plural'] ) ),
807 $current_sync_item['synced']
808 );
809 }
810
811 $this->output_success( $message );
812 }
813
814 /**
815 * Update last sync info.
816 *
817 * @since 4.2.0
818 */
819 protected function update_last_index() {
820 $start_time = $this->index_meta['start_time'];
821 $totals = $this->index_meta['totals'];
822 $method = $this->index_meta['method'];
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 Utils\update_option( 'ep_last_cli_index', $totals, false );
835 Utils\update_option( 'ep_last_index', $totals, false );
836 }
837
838 /**
839 * Make the necessary clean up after everything was sync'd.
840 *
841 * @since 4.0.0
842 */
843 protected function full_index_complete() {
844 $this->update_last_index();
845
846 /**
847 * Fires after executing a reindex
848 *
849 * @since 4.0.0
850 * @hook ep_after_sync_index
851 */
852 do_action( 'ep_after_sync_index' );
853
854 /**
855 * Fires after executing a reindex
856 *
857 * @since 3.5.5 Previously called only as 'ep_after_dashboard_index'
858 * @since 4.0.0 Made available for all methods
859 * @hook ep_after_{$index_method}_index
860 */
861 do_action( "ep_after_{$this->args['method']}_index" );
862
863 $this->output_success( esc_html__( 'Sync complete', 'elasticpress' ) );
864 }
865
866 /**
867 * Check if network aliases need to be created.
868 *
869 * @since 4.0.0
870 * @return boolean
871 */
872 protected function has_network_alias_to_be_created() {
873 return count( $this->index_meta['network_alias'] ) > 0;
874 }
875
876 /**
877 * Create the next network alias.
878 *
879 * @since 4.0.0
880 */
881 protected function create_network_alias() {
882 $indexes = [];
883 $indexable = Indexables::factory()->get( array_shift( $this->index_meta['network_alias'] ) );
884
885 $sites = Utils\get_sites();
886
887 foreach ( $sites as $site ) {
888 switch_to_blog( $site['blog_id'] );
889 $indexes[] = $indexable->get_index_name();
890 restore_current_blog();
891 }
892
893 $result = $indexable->create_network_alias( $indexes );
894
895 if ( $result ) {
896 $this->output_success(
897 sprintf(
898 /* translators: 1: Indexable name */
899 esc_html__( 'Network alias created for %1$s', 'elasticpress' ),
900 esc_html( strtolower( $indexable->labels['plural'] ) )
901 )
902 );
903 } else {
904 $this->output_error(
905 sprintf(
906 /* translators: 1: Indexable name */
907 esc_html__( 'Network alias creation failed for %1$s', 'elasticpress' ),
908 esc_html( strtolower( $indexable->labels['plural'] ) )
909 )
910 );
911 }
912 }
913
914 /**
915 * Output a message.
916 *
917 * @since 4.0.0
918 * @param string|array $message_text Message to be outputted
919 * @param string $type Type of message
920 * @param string $context Context of the output
921 * @return void
922 */
923 protected function output( $message_text, $type = 'info', $context = '' ) {
924 if ( $this->index_meta ) {
925 Utils\update_option( 'ep_index_meta', $this->index_meta );
926 } else {
927 Utils\delete_option( 'ep_index_meta' );
928 $totals = $this->get_last_index();
929 }
930
931 $message = [
932 'message' => ( is_array( $message_text ) ) ? implode( "\n", $message_text ) : $message_text,
933 'index_meta' => $this->index_meta,
934 'totals' => $totals ?? [],
935 'status' => $type,
936 ];
937
938 if ( is_callable( $this->args['output_method'] ) ) {
939 call_user_func( $this->args['output_method'], $message, $this->args, $this->index_meta, $context );
940 }
941 }
942
943 /**
944 * Wrapper to the `output` method with a success message.
945 *
946 * @since 4.0.0
947 * @param string $message Message string.
948 * @param string $context Context of the output.
949 */
950 protected function output_success( $message, $context = '' ) {
951 $this->output( $message, 'success', $context );
952 }
953
954 /**
955 * Wrapper to the `output` method with an error message.
956 *
957 * @since 4.0.0
958 * @param string $message Message string.
959 * @param string $context Context of the output.
960 */
961 protected function output_error( $message, $context = '' ) {
962 $this->output( $message, 'error', $context );
963 }
964
965 /**
966 * Output index errors of failed objects.
967 *
968 * @since 4.0.0
969 * @param array $failed_objects Failed objects
970 */
971 protected function output_index_errors( $failed_objects ) {
972 $indexable = Indexables::factory()->get( $this->index_meta['current_sync_item']['indexable'] );
973
974 $error_text = [];
975
976 foreach ( $failed_objects as $object ) {
977 $error_text[] = $object['index']['_id'] . ' (' . $indexable->labels['singular'] . '): [' . $object['index']['error']['type'] . '] ' . $object['index']['error']['reason'];
978 }
979
980 return $error_text;
981 }
982
983 /**
984 * Utilitary function to check if the indexable is being fully reindexed, i.e.,
985 * the index was deleted, a new mapping was sent and content is being reindexed.
986 *
987 * @param string $indexable_slug Indexable slug.
988 * @param int|null $blog_id Blog ID
989 * @return boolean
990 */
991 public function is_full_reindexing( $indexable_slug, $blog_id = null ) {
992 if ( empty( $this->index_meta ) || empty( $this->index_meta['put_mapping'] ) ) {
993 /**
994 * Filter if a fully reindex is being done to an indexable
995 *
996 * @since 4.0.0
997 * @hook ep_is_full_reindexing_{$indexable_slug}
998 * @param {bool} $is_full_reindexing If is fully reindexing
999 * @return {bool} New value
1000 */
1001 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", false );
1002 }
1003
1004 $sync_stack = ( ! empty( $this->index_meta['sync_stack'] ) ) ? $this->index_meta['sync_stack'] : [];
1005 $current_sync_item = ( ! empty( $this->index_meta['current_sync_item'] ) ) ? $this->index_meta['current_sync_item'] : [];
1006
1007 $is_full_reindexing = false;
1008
1009 $all_items = $sync_stack;
1010 if ( ! empty( $current_sync_item ) ) {
1011 $all_items += [ $current_sync_item ];
1012 }
1013
1014 foreach ( $all_items as $sync_item ) {
1015 if ( $sync_item['indexable'] !== $indexable_slug ) {
1016 continue;
1017 }
1018
1019 if (
1020 ( empty( $sync_item['blog_id'] ) && ! $blog_id ) ||
1021 (int) $sync_item['blog_id'] === $blog_id
1022 ) {
1023 $is_full_reindexing = true;
1024 }
1025 }
1026
1027 /* this filter is documented above */
1028 return apply_filters( "ep_is_full_reindexing_{$indexable_slug}", $is_full_reindexing );
1029 }
1030
1031 /**
1032 * Get the last index/sync meta information.
1033 *
1034 * @since 4.2.0
1035 * @return array
1036 */
1037 public function get_last_index() {
1038 return Utils\get_option( 'ep_last_index', [] );
1039 }
1040
1041 /**
1042 * Check if an object should be indexed or skipped.
1043 *
1044 * We used to have two different filters for this (one for the dashboard, another for CLI),
1045 * this method combines both.
1046 *
1047 * @param {stdClass} $object Object to be checked
1048 * @param {Indexable} $indexable Indexable
1049 * @return boolean
1050 */
1051 protected function should_skip_object_index( $object, $indexable ) {
1052 /**
1053 * Filter whether to not sync specific item in dashboard or not
1054 *
1055 * @since 2.1
1056 * @hook ep_item_sync_kill
1057 * @param {boolean} $kill False means dont sync
1058 * @param {array} $object Object to sync
1059 * @return {Indexable} Indexable that object belongs to
1060 */
1061 $ep_item_sync_kill = apply_filters( 'ep_item_sync_kill', false, $object, $indexable );
1062
1063 /**
1064 * Conditionally kill indexing for a post
1065 *
1066 * @hook ep_{indexable_slug}_index_kill
1067 * @param {bool} $index True means dont index
1068 * @param {int} $object_id Object ID
1069 * @return {bool} New value
1070 */
1071 $ep_indexable_sync_kill = apply_filters( 'ep_' . $indexable->slug . '_index_kill', false, $object->ID );
1072
1073 return $ep_item_sync_kill || $ep_indexable_sync_kill;
1074 }
1075
1076 /**
1077 * Resets some values to reduce memory footprint.
1078 */
1079 protected function stop_the_insanity() {
1080 global $wpdb, $wp_object_cache, $wp_actions;
1081
1082 $wpdb->queries = [];
1083
1084 /*
1085 * Runtime flushing was introduced in WordPress 6.0 and will flush only the
1086 * in-memory cache for persistent object caches
1087 */
1088 if ( function_exists( 'wp_cache_flush_runtime' ) ) {
1089 wp_cache_flush_runtime();
1090 } else {
1091 /*
1092 * In the case where we're not using an external object cache, we need to call flush on the default
1093 * WordPress object cache class to clear the values from the cache property
1094 */
1095 if ( ! wp_using_ext_object_cache() ) {
1096 wp_cache_flush();
1097 }
1098 }
1099
1100 if ( is_object( $wp_object_cache ) ) {
1101 $wp_object_cache->group_ops = [];
1102 $wp_object_cache->stats = [];
1103 $wp_object_cache->memcache_debug = [];
1104
1105 // Make sure this is a public property, before trying to clear it.
1106 try {
1107 $cache_property = new \ReflectionProperty( $wp_object_cache, 'cache' );
1108 if ( $cache_property->isPublic() ) {
1109 $wp_object_cache->cache = [];
1110 }
1111 unset( $cache_property );
1112 } catch ( \ReflectionException $e ) {
1113 // No need to catch.
1114 }
1115
1116 if ( is_callable( $wp_object_cache, '__remoteset' ) ) {
1117 call_user_func( [ $wp_object_cache, '__remoteset' ] );
1118 }
1119 }
1120
1121 // Prevent wp_actions from growing out of control.
1122 // phpcs:disable
1123 $wp_actions = $this->temporary_wp_actions;
1124 // phpcs:enable
1125
1126 // It's high memory consuming as WP_Query instance holds all query results inside itself
1127 // and in theory $wp_filter will not stop growing until Out Of Memory exception occurs.
1128 remove_filter( 'get_term_metadata', [ wp_metadata_lazyloader(), 'lazyload_term_meta' ] );
1129
1130 /**
1131 * Fires after reducing the memory footprint
1132 *
1133 * @since 4.3.0
1134 * @hook ep_stop_the_insanity
1135 */
1136 do_action( 'ep_stop_the_insanity' );
1137 }
1138
1139 /**
1140 * Utilitary function to delete the index meta option.
1141 *
1142 * @since 4.0.0
1143 */
1144 public function clear_index_meta() {
1145 Utils\delete_option( 'ep_index_meta', false );
1146 }
1147
1148 /**
1149 * Utilitary function to get the index meta option.
1150 *
1151 * @return array
1152 * @since 4.0.0
1153 */
1154 public function get_index_meta() {
1155 return Utils\get_option( 'ep_index_meta', [] );
1156 }
1157
1158 /**
1159 * Handle fatal errors during syncs.
1160 *
1161 * Added by register_shutdown_function. It will not be called if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1162 *
1163 * @since 4.2.0
1164 */
1165 public function handle_index_error() {
1166 $error = error_get_last();
1167 if ( empty( $error['type'] ) || E_ERROR !== $error['type'] ) {
1168 return;
1169 }
1170
1171 $this->on_error_update_and_clean( $error );
1172 }
1173
1174 /**
1175 * Handle fatal errors during syncs.
1176 *
1177 * Added via the `wp_php_error_message` filter. It will be called only if `WP_DISABLE_FATAL_ERROR_HANDLER` is false (default.)
1178 *
1179 * @since 4.2.0
1180 * @param bool $message HTML error message to display.
1181 * @param array $error Error information retrieved from error_get_last().
1182 * @return bool
1183 */
1184 public function wp_handle_index_error( $message, $error ) {
1185 $this->on_error_update_and_clean( $error );
1186 return $message;
1187 }
1188
1189 /**
1190 * Logs the error and clears the sync status, preventing the sync status from being stuck.
1191 *
1192 * @since 4.2.0
1193 * @param array $error Error information retrieved from error_get_last().
1194 */
1195 protected function on_error_update_and_clean( $error ) {
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 $this->output_error(
1214 sprintf(
1215 /* translators: Error message */
1216 esc_html__( 'Index failed: %s', 'elasticpress' ),
1217 $error['message']
1218 )
1219 );
1220 }
1221
1222 /**
1223 * Return singleton instance of class.
1224 *
1225 * @return self
1226 * @since 4.0.0
1227 */
1228 public static function factory() {
1229 static $instance = false;
1230
1231 if ( ! $instance ) {
1232 $instance = new self();
1233 $instance->setup();
1234 }
1235
1236 return $instance;
1237 }
1238 }
1239