�PNG  IHDR22?�� pHYs  �� OiCCPPhotoshop ICC profilexڝSgTS�=���BK���KoR RB���&*! J�!��Q�EEȠ�����Q, � ��!���������{�kּ������>���� �H3Q5� �B�������.@� $p�d!s�#�~<<+"��x� �M��0���B�\���t�8K�@z�B�@F���&S�`�cb�P-`'������{[�!�� e�Dh;��V�EX0fK�9�-0IWfH���� � 0Q��){`�##x��F�W<�+��*x��<�$9E�[-qWW.(�I+6aa�@.�y�2�4���������x����6��_-��"bb���ϫp@�t~��,/��;�m��%�h^ �u��f�@����W�p�~<�5�j>{�-�]c�K'Xt����o��(�h���w��?�G�%�fI�q^D$.Tʳ?�D��*�A��,���� �`6�B$��BB d�r`)��B(�Ͱ*`/�@4�Qh��p.�U�=p�a��(�� A�a!ڈb�X#����!�H�$ ɈQ"K�5H1R�T UH�=r9�\F��;�2����G1���Q=� �C��7�F� �dt1�����r�=�6��Ыhڏ>C�0��3�l0.��B�8, �c˱"� ���V����cϱw�E� 6wB aAHXLXN�H� $4� 7 �Q�'"��K�&���b21�XH,#��/{�C�7$�C2'��I��T��F�nR#�,��4H#���dk�9�, +ȅ����3��!�[ �b@q��S�(R�jJ��4�e�2AU��Rݨ�T5�ZB���R�Q��4u�9̓IK�����hh�i��t�ݕN��W���G���w ��Ljg(�gw��L�Ӌ�T071���oUX*�*|�� �J�&�*/T����ު U�U�T��^S}�FU3S� Ԗ�U��P�SSg�;���g�oT?�~Y��Y�L�OC�Q��_�� c�x,!k ��u�5�&���|v*�����=���9C3J3W�R�f?�q��tN �(���~���)�)�4L�1e\k����X�H�Q�G�6������E�Y��A�J'\'Gg����S�Sݧ �M=:��.�k���Dw�n��^��Lo��y���}/�T�m���G X� $� �<�5qo</���QC]�@C�a�a�ᄑ��<��F�F�i�\�$�m�mƣ&&!&KM�M�RM��)�;L;L���͢�֙5�=1�2��כ߷`ZxZ,����eI��Z�Yn�Z9Y�XUZ]�F���%ֻ�����N�N���gð�ɶ�����ۮ�m�}agbg�Ů��}�}��= ���Z~s�r:V:ޚΜ�?}����/gX���3��)�i�S��Ggg�s�󈋉K��.�>.���Ƚ�Jt�q]�z���������ۯ�6�i�ܟ�4�)�Y3s���C�Q��? ��0k߬~OCO�g��#/c/�W�װ��w��a�>�>r��>�<7�2�Y_�7��ȷ�O�o�_��C#�d�z����%g��A�[��z|!��?:�e����A���AA�������!h�쐭!��Α�i�P~���a�a��~ '���W�?�p�X�1�5w��Cs�D�D�Dޛg1O9�-J5*>�.j<�7�4�?�.fY��X�XIlK9.*�6nl������� �{�/�]py�����.,:�@L�N8��A*��%�w%� y��g"/�6ш�C\*N�H*Mz�쑼5y$�3�,幄'���L Lݛ:��v m2=:�1����qB�!M��g�g�fvˬe����n��/��k���Y- �B��TZ(�*�geWf�͉�9���+��̳�ې7�����ᒶ��KW-X潬j9�������(�x��oʿ�ܔ���Ĺd�f�f���-�[����n �ڴ �V����E�/��(ۻ��C���<��e����;?T�T�T�T6��ݵa��n��{��4���[���>ɾ�UUM�f�e�I���?�������m]�Nmq����#�׹���=TR��+�G�����w- 6 U����#pDy��� �� :�v�{���vg/jB��F�S��[b[�O�>����z�G��499�?r����C�d�&����ˮ/~�����јѡ�򗓿m|������������x31^�V���w�w��O�| (�h���SЧ��������c3-� cHRMz%������u0�`:�o�_�F5IDATx��ytUս�?�;���fN$$��@B�!�k��� -*�gE���j�O�kQ�ʠ`U�B_ �AÐ0%�Ȕ�@r3ޛ;߳��$�@��v����^�s����������$��ߢ !�W��5�̱'-٘������4�>�Rƫ$�G��0U�H}�_ _� #��k�MJ/=�9Du *ڲ�k�PQ;0��j|�*�$�^컂脛y���>�z×� B��� ��(�:$�ʔ_�6��C!B����MVx��a��ζyY����j��ƋY2�9�F�{r��r�딟F��Q��hY6��� x��[l��RC��������nt@��;P��3��� F�w��7��:�P���ɋ'���mj���Bƪ�$�Q�L��y��߷g��\���z����v5�p�v$�v櫻�`6~K �Y�X����,�:��"�o�e0II"(��,������l_�C���/4�+f�E�!q��:����u����c��樼.UE�@� �]K�W���$-�h]�JG���Dz)�`��.�A/ ���mᏣc�3jK� �!�>���$�Q�!�_�? \�C2(E �M$Z I{�U���s�ǣ���=��@Բe ޻��W�����@ �w����}�9v,)��.}*|"�s��di���,���G�]W�AC�������|<����[��ߕ������K�*�G nߴ!��‹��J�!��6(L�a)��Am��������;adf��:�xm��nh�;֦I��(���o�C~ډa?�=��s����h��E������xp����)���_���t��W�����´;8���`dz=(��@@�+,�;�`�fhl��XȌ�3,uI�~z(J����� �ZX�A����H'�À�㜦�82|��a�n�z2��I���� ���EY�h�ε<�0�q�{��C��v�˅��?"@��χ�x�WS+��N�~�KQ�J�8ل�� _.W��� !TU{�����,��"�� �;�������HI S� ��))�o�1�H�D� ���� (� ;����"Ҕ��,���� �F=���O��&�Ph>�`���y��߲ &S��P�<��'��1s�1cĂ�N����2)����@�u��tv��<�+�-&�����h��[!FSG���' � z z0��<����8d&�����E0�,��<��"!P�b4��� �{S�����X����.�囤���T��hEF��X�q0"-<����h���]�Py*����F⩍��@v�˂�Y08�� .q��a��s����(!<̫1�3�L�H��7sYW�`��qtu�\7��?�@�Y�� %Ka�x�GZ�*W�P��y�����N0r9��Z�c�%���q,��k�����!!,��>���IB��� �x1�Xu�Y���v-���ֺ��N������JP��a�2`�e5��w��u;�6�i���̃��(K���ťa��(l51��JL��O'%���]���ٖ뿉!��{*�p+f ���i8��D�ȑ���ܩ�M��V��ӟz��x�7Z�1i}�^͜���6e����x��)u�d�ˏ�V�eG�\�X1�����=n�U'�~Y����L�\,�(d!x ,�:�TW8� M`r���@��r+v�Q0��>^;˴�VbF��('����� �*O��i��j�Ӆ����^�S3&� ���{������.ی�� N;��NU%d0�Ce\r"�C�b��&t7N�7��� �ȪD�[%l�۪�$��'m?�A9tI�9>��NVL ��� 6v~M�����ъ�B���0����9$&=ώm��W�0���L���&��E���f�f��'+�9��hv��7,���M��6�D�&�^� �����8 D֮�����o��k^z��O��hP�8q�5d*��YVq3�{��������#LX4�?��jP=3�~�؅c�ݰ�̱�ұ��8̲��2�G�����j��έ�9��w7���U�X�w�85C��?!I&���o&����L���I#�^�eV�,�^t5�k� �xs˛\��U�8��."���{���ǵ�l4��ݼ�;��r"���G�/~���gJV�N糦��ZZ^k+NN�D��I$�=OE�z����|*�*{��%k���G��>�`9��b;�s���8,�qs��c��.b��q�%�>r��n�R�Ƨ�%�,�A��le#�8���}; 21�0�b�&�QX�f���q�00����$>�h�N�'�a3��A���/�r ��L5�8�GԲ'�����w;��c#�q1���h$*��V^�k��QĽ�b:i������� ����H F#�_���,��]g����Ը����`RUU���� ��"�qD���>-��ϤI�~��3�̴�����NZ[[555�eeek7�m����YF#[��Đ��R��i��L�H�r ��@��J-�Pŝ��_|�-��6y��ks�V�U~��6���w.����(��0A� 4��f��� /���C�xŊ�ٳ����z=6�����rg���mKo��o�/����a�g���?'9%9��P��斓��~���L{�� �H��{AW��B��$����v�1b�&Mb�ر��ر#�0`�`xMBp! )Z�Nmo�mZ�g�}�eذaWTVV ���'99��C5��&�&+5�bT>|8��PUUETT����&���t��~TE%��'������b�tEGG;RSS�S�N�v��ѯ��t����u1@�ޚ"f��͈���r'L�Ph��m��ƌ�74o��`�x�q�P���NRSS�X,�����l&&&��MFFA!-��`��ҷ�n��Yuu�����x<I�LB%����KԒ50�!�3�bz�����ʸKL���;---�l6[��`��������Ȏ;�����egg4��!D�T��jWujyZ��������K����%����p�^���X,6��sV=�ܖ����6�3�X ���4ҿ��A2�&����_k�;d�� �VfOIEND�B`�
Warning: session_start(): Session cannot be started after headers have already been sent in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 124

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 130

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 131

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 132

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 133

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 134

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 135

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 547

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 548

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 549

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 550

Warning: Cannot modify header information - headers already sent by (output started at /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php:1) in /home/mciraet/www/wp-includes/php-ai-client/src/Providers/Enums/Documentsoliders.php on line 551
handle_watchdog_action(); } ); add_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, function ( $batch_process ) { $this->process_next_batch_for_single_processor( $batch_process ); }, 10, 2 ); add_action( 'shutdown', function () { $this->remove_or_retry_failed_processors(); } ); $this->logger = wc_get_logger(); } /** * Enqueue a processor so that it will get batch processing requests from within scheduled actions. * * @param string $processor_class_name Fully qualified class name of the processor, must implement `BatchProcessorInterface`. */ public function enqueue_processor( string $processor_class_name ): void { /* * Fast path: if the processor is already enqueued and the stored list is clean, there is nothing to write. * This avoids taking the lock and re-reading the option on the hot path, which matters because * DataSynchronizer::handle_continuous_background_sync() calls this on the 'shutdown' hook of every request * when HPOS background sync runs in continuous mode. The check uses the (possibly request-cached) value; * the authoritative re-read happens inside the critical section below only when an update is actually needed. */ if ( $this->enqueued_list_needs_update( $this->get_enqueued_processors(), $processor_class_name ) ) { $this->mutate_enqueued_processors( function ( array $pending_updates ) use ( $processor_class_name ): array { $deduplicated_updates = $this->sanitize_processor_list( $pending_updates ); if ( ! in_array( $processor_class_name, $deduplicated_updates, true ) ) { $deduplicated_updates[] = $processor_class_name; } return $deduplicated_updates; } ); } $this->schedule_watchdog_action( false, true ); } /** * Reduce a processor list to unique, non-empty class-name strings, preserving order. * * The enqueued-processors option is a plain serialized array, so a corrupted option (or the historical * duplicate-accumulation bug) can leave it holding duplicates, empty strings, or non-string values. Sanitizing * before any comparison keeps the mutators safe and heals the stored list on the next write: in particular * array_diff() string-casts its operands and would fatal on an object entry in PHP 8, so removals run on the * sanitized list. Empty strings are dropped too: they are never a valid class name, and left in place would be * scheduled and then fatal in get_processor_instance( '' ), with the watchdog rescheduling them indefinitely. * * @since 11.0.0 * * @param array $processors Raw processor list as read from the option. * @return array Sanitized list of unique class-name strings. */ private function sanitize_processor_list( array $processors ): array { $sanitized = array(); $seen = array(); foreach ( $processors as $value ) { if ( is_string( $value ) && '' !== $value && ! isset( $seen[ $value ] ) ) { $seen[ $value ] = true; $sanitized[] = $value; } } return $sanitized; } /** * Determine whether the stored enqueued-processors list needs to be rewritten to include a given processor * or to heal pre-existing corruption (duplicates or non-string entries). * * @since 11.0.0 * * @param array $processors Current list of enqueued processors. * @param string $processor_class_name Fully qualified class name of the processor to ensure is enqueued. * * @return bool True if the list should be rewritten, false if it is already clean and contains the processor. */ private function enqueued_list_needs_update( array $processors, string $processor_class_name ): bool { $seen = array(); $contains_class = false; foreach ( $processors as $value ) { if ( ! is_string( $value ) || isset( $seen[ $value ] ) ) { // Non-string entry or duplicate: the list needs healing. return true; } $seen[ $value ] = true; if ( $value === $processor_class_name ) { $contains_class = true; } } return ! $contains_class; } /** * Run a read-modify-write of the enqueued-processors option inside a short-lived critical section. * * The list is stored in a single option and mutated by several code paths (including the per-request * 'shutdown' enqueue from continuous HPOS background sync), so an unguarded read-modify-write can lose * updates under concurrency: two requests read the same list, each writes its own version, and the later * write silently drops the other's change. This serializes those mutations with a self-expiring options-table * mutex and re-reads the freshest persisted value inside the lock so the mutator always operates on current * state. * * The lock is best-effort: if it cannot be acquired within ENQUEUED_PROCESSORS_LOCK_ATTEMPTS tries the mutation * still proceeds, which is no worse than the previous lock-free behavior. The fresh re-read inside the section * narrows the race window even when the lock provides no real exclusion, and the watchdog reconciles any * residual divergence on its next run. * * @since 11.0.0 * * @param callable $mutator Receives the current list (array of class-name strings) and returns the new list. * * @return array The list as persisted (the mutator's return value). */ private function mutate_enqueued_processors( callable $mutator ): array { $lock_token = $this->acquire_enqueued_processors_lock(); try { /* * Drop any request-cached copy so the read below reflects writes committed by concurrent requests * that landed after this request first read the option. Both the per-option entry AND the shared * 'notoptions' entry must be cleared: when the option does not yet exist (first enqueue, or after a * corrupted option was deleted), get_option() short-circuits on a stale 'notoptions' hit and would * otherwise return the default empty list even though a concurrent request just created the row. */ wp_cache_delete( self::ENQUEUED_PROCESSORS_OPTION_NAME, 'options' ); wp_cache_delete( 'notoptions', 'options' ); $current = $this->get_enqueued_processors(); $updated = $mutator( $current ); if ( $updated !== $current ) { $this->set_enqueued_processors( $updated ); } return $updated; } finally { if ( null !== $lock_token ) { $this->release_enqueued_processors_lock( $lock_token ); } } } /** * Acquire the enqueued-processors lock by claiming a row in the options table. * * The claim is an atomic INSERT (which fails if the row already exists, making it a mutex); if the row exists * but its stored release time has already passed, a conditional UPDATE takes the stale lock over. Failure to * claim is retried up to ENQUEUED_PROCESSORS_LOCK_ATTEMPTS times with a short delay before the caller falls * back to its best-effort unguarded path. Modeled on WC_Install::create_lock(). * * Release times are stored as fixed-width, zero-padded-fraction strings (`number_format( ..., 6 )`) so the * database compares and matches them as plain strings without a numeric cast; for the current epoch the * integer part is a stable ten digits, so lexical order matches chronological order. * * @since 11.0.0 * * @return string|null The stored release time when the lock was acquired (which must be passed to * release_enqueued_processors_lock()), or null when it could not be acquired. */ private function acquire_enqueued_processors_lock(): ?string { global $wpdb; $attempts = max( 1, self::ENQUEUED_PROCESSORS_LOCK_ATTEMPTS ); $delay_micro = (int) ( ( self::ENQUEUED_PROCESSORS_LOCK_TTL / $attempts ) * 1_000_000 ); // A contended INSERT fails on the duplicate key by design, so silence wpdb error reporting to keep the // expected-failure path from spamming the log in debug mode; restore the prior setting when done. $suppress = $wpdb->suppress_errors( true ); try { for ( $attempt = 0; $attempt < $attempts; $attempt++ ) { $time = microtime( true ); $now = number_format( $time, 6, '.', '' ); $expiry = number_format( $time + self::ENQUEUED_PROCESSORS_LOCK_TTL, 6, '.', '' ); // The unique option_name key makes this INSERT a mutex: it fails when the lock row already exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $acquired = $wpdb->insert( $wpdb->options, array( 'option_name' => self::ENQUEUED_PROCESSORS_LOCK_OPTION, 'option_value' => $expiry, 'autoload' => 'no', ), array( '%s', '%s', '%s' ) ); if ( ! $acquired ) { /* * The lock row exists; take it over only if its stored release time is already in the past. * $wpdb->update() cannot express the "<" comparison, so this conditional takeover uses raw SQL. * The table is a trusted internal identifier and every value is bound through a placeholder. */ $takeover = $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $expiry, self::ENQUEUED_PROCESSORS_LOCK_OPTION, $now ); if ( is_string( $takeover ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared $acquired = $wpdb->query( $takeover ); } } if ( $acquired ) { return $expiry; } if ( $attempt < $attempts - 1 && $delay_micro > 0 ) { usleep( $delay_micro ); } } return null; } finally { $wpdb->suppress_errors( $suppress ); } } /** * Release the enqueued-processors lock by deleting the options-table row we own. * * The delete is conditional on the stored release time still matching the one written when the lock was * acquired, so a lock that expired and was taken over by another request is never released out from under it. * * @since 11.0.0 * * @param string $expiry The release time returned by acquire_enqueued_processors_lock(). */ private function release_enqueued_processors_lock( string $expiry ): void { global $wpdb; $suppress = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->delete( $wpdb->options, array( 'option_name' => self::ENQUEUED_PROCESSORS_LOCK_OPTION, 'option_value' => $expiry, ), array( '%s', '%s' ) ); } finally { $wpdb->suppress_errors( $suppress ); } } /** * Schedule the watchdog action. * * @param bool $with_delay Whether to delay the action execution. Should be true when rescheduling, false when enqueueing. * @param bool $unique Whether to make the action unique. */ private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void { $time = time(); if ( $with_delay ) { /** * Modify the delay interval for the batch processor's watchdog events. * * @since 8.2.0 * * @param int $delay Time, in seconds, before the watchdog process will run. Defaults to 3600 (1 hour). */ $time += apply_filters( 'woocommerce_batch_processor_watchdog_delay_seconds', HOUR_IN_SECONDS ); } if ( ! as_has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) { as_schedule_single_action( $time, self::WATCHDOG_ACTION_NAME, array(), self::ACTION_GROUP, $unique ); } } /** * Schedule a processing action for all the processors that are enqueued but not scheduled * (because they have just been enqueued, or because the processing for a batch failed). */ private function handle_watchdog_action(): void { $pending_processes = $this->sanitize_processor_list( $this->get_enqueued_processors() ); if ( empty( $pending_processes ) ) { return; } foreach ( $pending_processes as $process_name ) { if ( ! $this->is_scheduled( $process_name ) ) { $this->schedule_batch_processing( $process_name ); } } $this->schedule_watchdog_action( true ); } /** * Process a batch for a single processor, and handle any required rescheduling or state cleanup. * * @param string $processor_class_name Fully qualified class name of the processor. * * @throws \Exception If error occurred during batch processing. */ private function process_next_batch_for_single_processor( string $processor_class_name ): void { if ( ! $this->is_enqueued( $processor_class_name ) ) { return; } $batch_processor = $this->get_processor_instance( $processor_class_name ); $error = $this->process_next_batch_for_single_processor_core( $batch_processor ); $still_pending = count( $batch_processor->get_next_batch_to_process( 1 ) ) > 0; if ( ( $error instanceof \Exception ) ) { // The batch processing failed and no items were processed: // reschedule the processing with a delay, unless this is a repeatead failure. if ( $this->is_consistently_failing( $batch_processor ) ) { $this->log_consistent_failure( $batch_processor, $this->get_process_details( $batch_processor ) ); $this->remove_processor( $processor_class_name ); } else { $this->schedule_batch_processing( $processor_class_name, true ); } throw $error; } if ( $still_pending ) { $this->schedule_batch_processing( $processor_class_name ); } else { $this->dequeue_processor( $processor_class_name ); } } /** * Process a batch for a single processor, updating state and logging any error. * * @param BatchProcessorInterface $batch_processor Batch processor instance. * * @return null|\Exception Exception if error occurred, null otherwise. */ private function process_next_batch_for_single_processor_core( BatchProcessorInterface $batch_processor ): ?\Exception { $details = $this->get_process_details( $batch_processor ); $time_start = microtime( true ); $batch = $batch_processor->get_next_batch_to_process( $details['current_batch_size'] ); if ( empty( $batch ) ) { return null; } try { $batch_processor->process_batch( $batch ); $time_taken = microtime( true ) - $time_start; $this->update_processor_state( $batch_processor, $time_taken ); } catch ( \Exception $exception ) { $time_taken = microtime( true ) - $time_start; $this->log_error( $exception, $batch_processor, $batch ); $this->update_processor_state( $batch_processor, $time_taken, $exception ); return $exception; } return null; } /** * Get the current state for a given enqueued processor. * * @param BatchProcessorInterface $batch_processor Batch processor instance. * * @return array Current state for the processor, or a "blank" state if none exists yet. */ private function get_process_details( BatchProcessorInterface $batch_processor ): array { $defaults = array( 'total_time_spent' => 0, 'current_batch_size' => $batch_processor->get_default_batch_size(), 'last_error' => null, 'recent_failures' => 0, 'batch_first_failure' => null, 'batch_last_failure' => null, ); $process_details = get_option( $this->get_processor_state_option_name( $batch_processor ) ); $process_details = wp_parse_args( is_array( $process_details ) ? $process_details : array(), $defaults ); return $process_details; } /** * Get the name of the option where we will be saving state for a given processor. * * @param BatchProcessorInterface|string $batch_processor Batch processor instance or class name. * * @return string Option name. */ private function get_processor_state_option_name( $batch_processor ): string { $class_name = is_a( $batch_processor, BatchProcessorInterface::class ) ? get_class( $batch_processor ) : $batch_processor; $class_md5 = md5( $class_name ); // truncate the class name so we know that it will fit in the option name column along with md5 hash and prefix. $class_name = substr( $class_name, 0, 140 ); return 'wc_batch_' . $class_name . '_' . $class_md5; } /** * Update the state for a processor after a batch has completed processing. * * @param BatchProcessorInterface $batch_processor Batch processor instance. * @param float $time_taken Time take by the batch to complete processing. * @param \Exception|null $last_error Exception object in processing the batch, if there was one. */ private function update_processor_state( BatchProcessorInterface $batch_processor, float $time_taken, ?\Exception $last_error = null ): void { $current_status = $this->get_process_details( $batch_processor ); $current_status['total_time_spent'] += $time_taken; $current_status['last_error'] = null !== $last_error ? $last_error->getMessage() : null; if ( null !== $last_error ) { $current_status['recent_failures'] = ( $current_status['recent_failures'] ?? 0 ) + 1; $current_status['batch_last_failure'] = current_time( 'mysql' ); if ( is_null( $current_status['batch_first_failure'] ) ) { $current_status['batch_first_failure'] = $current_status['batch_last_failure']; } } else { $current_status['recent_failures'] = 0; $current_status['batch_first_failure'] = null; $current_status['batch_last_failure'] = null; } update_option( $this->get_processor_state_option_name( $batch_processor ), $current_status, false ); } /** * Removes the option where we store state for a given processor. * * @since 9.1.0 * * @param string $processor_class_name Fully qualified class name of the processor. */ private function clear_processor_state( string $processor_class_name ): void { delete_option( $this->get_processor_state_option_name( $processor_class_name ) ); } /** * Schedule a processing action for a single processor. * * @param string $processor_class_name Fully qualified class name of the processor. * @param bool $with_delay Whether to schedule the action for immediate execution or for later. */ private function schedule_batch_processing( string $processor_class_name, bool $with_delay = false ): void { $time = $with_delay ? time() + MINUTE_IN_SECONDS : time(); as_schedule_single_action( $time, self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) ); } /** * Check if a batch processing action is already scheduled for a given processor. * Differs from `as_has_scheduled_action` in that this excludes actions in progress. * * @param string $processor_class_name Fully qualified class name of the batch processor. * * @return bool True if a batch processing action is already scheduled for the processor. */ public function is_scheduled( string $processor_class_name ): bool { return as_has_scheduled_action( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) ); } /** * Get an instance of a processor given its class name. * * @param string $processor_class_name Full class name of the batch processor. * * @return BatchProcessorInterface Instance of batch processor for the given class. * @throws \Exception If it's not possible to get an instance of the class. */ private function get_processor_instance( string $processor_class_name ): BatchProcessorInterface { $container = wc_get_container(); $processor = $container->has( $processor_class_name ) ? $container->get( $processor_class_name ) : null; /** * Filters the instance of a processor for a given class name. * * @param object|null $processor The processor instance given by the dependency injection container, or null if none was obtained. * @param string $processor_class_name The full class name of the processor. * @return BatchProcessorInterface|null The actual processor instance to use, or null if none could be retrieved. * * @since 6.8.0. */ $processor = apply_filters( 'woocommerce_get_batch_processor', $processor, $processor_class_name ); if ( ! isset( $processor ) && class_exists( $processor_class_name ) ) { // This is a fallback for when the batch processor is not registered in the container. $processor = new $processor_class_name(); } if ( ! is_a( $processor, BatchProcessorInterface::class ) ) { throw new \Exception( "Unable to initialize batch processor instance for $processor_class_name" ); } return $processor; } /** * Helper method to get list of all the enqueued processors. * * @return array List (of string) of the class names of the enqueued processors. */ public function get_enqueued_processors(): array { $enqueued_processors = get_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array() ); if ( ! is_array( $enqueued_processors ) ) { $this->logger->error( 'Could not fetch list of processors. Clearing up queue.', array( 'source' => 'batch-processing' ) ); delete_option( self::ENQUEUED_PROCESSORS_OPTION_NAME ); $enqueued_processors = array(); } return $enqueued_processors; } /** * Dequeue a processor once it has no more items pending processing. * * @param string $processor_class_name Full processor class name. */ private function dequeue_processor( string $processor_class_name ): void { // Always resolve membership authoritatively inside the lock (no unguarded fast-path read): this runs when a // batch finishes, not on a per-request hot path, so correctness is preferred over skipping the lock. $removed = false; $this->mutate_enqueued_processors( function ( array $pending_processes ) use ( $processor_class_name, &$removed ): array { if ( ! in_array( $processor_class_name, $pending_processes, true ) ) { return $pending_processes; } $removed = true; return array_values( array_diff( $this->sanitize_processor_list( $pending_processes ), array( $processor_class_name ) ) ); } ); if ( $removed ) { $this->clear_processor_state( $processor_class_name ); } } /** * Helper method to set the enqueued processor class names. * * @param array $processors List of full processor class names. */ private function set_enqueued_processors( array $processors ): void { update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, $processors, false ); } /** * Check if a particular processor is enqueued. * * @param string $processor_class_name Fully qualified class name of the processor. * * @return bool True if the processor is enqueued. */ public function is_enqueued( string $processor_class_name ): bool { return in_array( $processor_class_name, $this->get_enqueued_processors(), true ); } /** * Dequeue and de-schedule a processor instance so that it won't be processed anymore. * * @param string $processor_class_name Fully qualified class name of the processor. * @return bool True if the processor has been dequeued, false if the processor wasn't enqueued (so nothing has been done). */ public function remove_processor( string $processor_class_name ): bool { // Resolve membership authoritatively inside the lock (no unguarded fast-path read). remove_processor() is // not a per-request hot path — the continuous-sync 'shutdown' handler enqueues rather than removes — so it // always takes the lock and re-reads the freshest list rather than acting on a possibly-stale cached copy. $was_enqueued = false; $remaining_processors = $this->mutate_enqueued_processors( function ( array $enqueued_processors ) use ( $processor_class_name, &$was_enqueued ): array { if ( ! in_array( $processor_class_name, $enqueued_processors, true ) ) { return $enqueued_processors; } $was_enqueued = true; return array_values( array_diff( $this->sanitize_processor_list( $enqueued_processors ), array( $processor_class_name ) ) ); } ); if ( ! $was_enqueued ) { return false; } /* * $remaining_processors is the list exactly as persisted inside the critical section above, so the empty * check is decided from the lock-guarded snapshot rather than a second, unguarded re-read (which would only * ever see this request's own post-mutation value anyway). When that snapshot is empty we unschedule every * single-batch action to sweep up any orphaned "ghost" actions left in Action Scheduler for processors that * are no longer enqueued; otherwise only the removed processor's own scheduling needs tearing down, so we * unschedule by class name. * * This intentionally does NOT unschedule the watchdog: handle_watchdog_action() returns without rescheduling * itself once the list is empty (so a lingering watchdog fires at most once more and then stops), and leaving * it in place is what makes the empty-list sweep safe. force_clear_all_processes() is deliberately avoided: * its own unguarded read-modify-write would clobber a processor that a concurrent request enqueued in the gap * after this mutation committed — the exact lost-update race this change exists to prevent. * * There is still a narrow window: a concurrent request can enqueue processor Q and have the watchdog schedule * Q's single-batch action after our lock releases but before the sweep runs, in which case the sweep cancels * Q's action even though Q remains enqueued. Q is never lost from the option (the source of truth), so this is * a bounded scheduling delay, not a lost update: the watchdog we leave in place reschedules Q on its next run. * That recovery is bounded by the watchdog delay (woocommerce_batch_processor_watchdog_delay_seconds), not the * next request, because remove_or_retry_failed_processors() no-ops while any watchdog is already scheduled. */ if ( empty( $remaining_processors ) ) { as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME ); } else { as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME, array( $processor_class_name ) ); } $this->clear_processor_state( $processor_class_name ); return true; } /** * Dequeues and de-schedules all the processors. */ public function force_clear_all_processes(): void { as_unschedule_all_actions( self::PROCESS_SINGLE_BATCH_ACTION_NAME ); as_unschedule_all_actions( self::WATCHDOG_ACTION_NAME ); foreach ( $this->get_enqueued_processors() as $processor ) { $this->clear_processor_state( $processor ); } update_option( self::ENQUEUED_PROCESSORS_OPTION_NAME, array(), false ); } /** * Log an error that happened while processing a batch. * * @param \Exception $error Exception object to log. * @param BatchProcessorInterface $batch_processor Batch processor instance. * @param array $batch Batch that was being processed. */ protected function log_error( \Exception $error, BatchProcessorInterface $batch_processor, array $batch ): void { $error_message = "Error processing batch for {$batch_processor->get_name()}: {$error->getMessage()}"; $error_context = array( 'exception' => $error, 'source' => 'batch-processing', ); // Log only first and last, as the entire batch may be too big. if ( count( $batch ) > 0 ) { $error_context = array_merge( $error_context, array( 'batch_start' => $batch[0], 'batch_end' => end( $batch ), ) ); } /** * Filters the error message for a batch processing. * * @param string $error_message The error message that will be logged. * @param \Exception $error The exception that was thrown by the processor. * @param BatchProcessorInterface $batch_processor The processor that threw the exception. * @param array $batch The batch that was being processed. * @param array $error_context Context to be passed to the logging function. * @return string The actual error message that will be logged. * * @since 6.8.0 */ $error_message = apply_filters( 'wc_batch_processing_log_message', $error_message, $error, $batch_processor, $batch, $error_context ); $this->logger->error( $error_message, $error_context ); } /** * Determines whether a given processor is consistently failing based on how many recent consecutive failures it has had. * * @since 9.1.0 * * @param BatchProcessorInterface $batch_processor The processor that we want to check. * @return boolean TRUE if processor is consistently failing. FALSE otherwise. */ private function is_consistently_failing( BatchProcessorInterface $batch_processor ): bool { $process_details = $this->get_process_details( $batch_processor ); $max_attempts = absint( /** * Controls the failure threshold for batch processors. That is, the number of times we'll attempt to * process a batch that has resulted in a failure. Once above this threshold, the processor won't be * re-scheduled and will be removed from the queue. * * @since 9.1.0 * * @param int $failure_threshold Maximum number of times for the processor to try processing a given batch. * @param BatchProcessorInterface $batch_processor The processor instance. * @param array $process_details Array with batch processor state. */ apply_filters( 'wc_batch_processing_max_attempts', self::FAILING_PROCESS_MAX_ATTEMPTS_DEFAULT, $batch_processor, $process_details ) ); return absint( $process_details['recent_failures'] ?? 0 ) >= max( $max_attempts, 1 ); } /** * Creates log entry with details about a batch processor that is consistently failing. * * @since 9.1.0 * * @param BatchProcessorInterface $batch_processor The batch processor instance. * @param array $process_details Failing process details. */ private function log_consistent_failure( BatchProcessorInterface $batch_processor, array $process_details ): void { $this->logger->error( "Batch processor {$batch_processor->get_name()} appears to be failing consistently: {$process_details['recent_failures']} unsuccessful attempt(s). No further attempts will be made.", array( 'source' => 'batch-processing', 'failures' => $process_details['recent_failures'], 'first_failure' => $process_details['batch_first_failure'], 'last_failure' => $process_details['batch_last_failure'], ) ); } /** * Hooked onto 'shutdown'. This cleanup routine checks enqueued processors and whether they are scheduled or not to * either re-eschedule them or remove them from the queue. * This prevents stale states where Action Scheduler won't schedule any more attempts but we still report the * processor as enqueued. * * @since 9.1.0 */ private function remove_or_retry_failed_processors(): void { if ( ! did_action( 'wp_loaded' ) ) { return; } $last_error = error_get_last(); if ( ! is_null( $last_error ) && in_array( $last_error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ), true ) ) { return; } // The most efficient way to check for an existing action is to use `as_has_scheduled_action`, but in unusual // cases where another plugin has loaded a very old version of Action Scheduler, it may not be available to us. $has_scheduled_action = function_exists( 'as_has_scheduled_action') ? 'as_has_scheduled_action' : 'as_next_scheduled_action'; if ( call_user_func( $has_scheduled_action, self::WATCHDOG_ACTION_NAME ) ) { return; } /* * Sanitize before array_diff()/array_filter(): array_diff() string-casts its operands (fatal on an object * entry in PHP 8) and is_scheduled() is typed string, so a corrupted option must be reduced to class-name * strings first. */ $enqueued_processors = $this->sanitize_processor_list( $this->get_enqueued_processors() ); $unscheduled_processors = array_diff( $enqueued_processors, array_filter( $enqueued_processors, array( $this, 'is_scheduled' ) ) ); foreach ( $unscheduled_processors as $processor ) { try { $instance = $this->get_processor_instance( $processor ); } catch ( \Exception $e ) { continue; } $exception = new \Exception( 'Processor is enqueued but not scheduled. Background job was probably killed or marked as failed. Reattempting execution.' ); $this->update_processor_state( $instance, 0, $exception ); $this->log_error( $exception, $instance, array() ); if ( $this->is_consistently_failing( $instance ) ) { $this->log_consistent_failure( $instance, $this->get_process_details( $instance ) ); $this->remove_processor( $processor ); } else { $this->schedule_batch_processing( $processor, true ); } } } }