PluginProbe
ElasticPress / 4.4.0
ElasticPress v4.4.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.4.0, at includes/classes/IndexHelper.php

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