batch, so a low `max_execution_time` * does not abort generation part-way through a chunk. Return 0 to leave the time limit untouched. * This only affects PHP's own execution timeout, not Action Scheduler's failure period nor any * hard server/host request timeout. * * @param int $batch_time_limit The per-batch time limit in seconds. * * @since 11.0.0 */ return (int) apply_filters( 'woocommerce_product_feed_batch_time_limit', 5 * MINUTE_IN_SECONDS ); } /** * Returns the number of batches to process per chunk, derived from the effective chunk size. * * @param string $option_key The option key for the feed generation status. * @return int The number of batches per chunk (at least 1). */ private function get_chunk_batch_count( string $option_key ): int { /** * Filters the number of products processed per chunk during feed generation. * * Each chunk runs in its own Action Scheduler action and then schedules the next, keeping every * run short enough to finish within Action Scheduler's failure period and the host's request * timeout. Defaults to the effective chunk size, which starts large and shrinks if a run gets stuck. * * @param int $chunk_size The number of products to process per chunk. * * @since 11.0.0 */ $chunk_size = (int) apply_filters( 'woocommerce_product_feed_chunk_size', $this->get_effective_chunk_size( $option_key ) ); if ( $chunk_size < 1 ) { $chunk_size = self::CHUNK_SIZE_STEPS[0]; } return (int) max( 1, (int) ceil( $chunk_size / $this->get_batch_size() ) ); } /** * Returns the option key under which the effective chunk size is persisted. * * Stored separately from the status so a shrunk chunk size survives the status being cleared when a * job completes, expires, or restarts, and carries over to the next request from the app. * * @param string $option_key The option key for the feed generation status. * @return string The option key for the effective chunk size. */ private function get_chunk_size_option_key( string $option_key ): string { return $option_key . '_chunk_size'; } /** * Returns the effective chunk size (products per action) currently in use for a feed. * * @param string $option_key The option key for the feed generation status. * @return int The effective chunk size, defaulting to the largest configured step. */ private function get_effective_chunk_size( string $option_key ): int { $chunk_size = (int) get_option( $this->get_chunk_size_option_key( $option_key ), self::CHUNK_SIZE_STEPS[0] ); return $chunk_size > 0 ? $chunk_size : self::CHUNK_SIZE_STEPS[0]; } /** * Steps the chunk size down when an invalidated status was a stuck in-progress job. * * A stuck job was most likely killed because its chunk was too large for the host, so a smaller chunk * makes the restart more likely to fit. A genuine failure (state = failed) is a real error rather than * a size symptom and is intentionally excluded. Both recovery paths — an ordinary poll * ({@see get_status()}) and an explicit rebuild ({@see force_regeneration()}) — call this, so a stuck * job adapts the same way however it is recovered. * * @param array $status The invalidated status being discarded. * @param string $option_key The option key for the feed generation status. * @return void */ private function reduce_chunk_size_if_stuck( array $status, string $option_key ): void { if ( self::STATE_IN_PROGRESS === ( $status['state'] ?? '' ) ) { $this->reduce_chunk_size( $option_key ); } } /** * Steps the effective chunk size down to the next-smaller configured size and persists it. * * Called when a job gets stuck. Once at the smallest configured size it stays there. * * @param string $option_key The option key for the feed generation status. * @return int The new effective chunk size. */ private function reduce_chunk_size( string $option_key ): int { $current = $this->get_effective_chunk_size( $option_key ); // CHUNK_SIZE_STEPS is descending, so the first step smaller than the current size is the next rung down. $next = $current; foreach ( self::CHUNK_SIZE_STEPS as $step ) { if ( $step < $current ) { $next = $step; break; } } update_option( $this->get_chunk_size_option_key( $option_key ), $next ); wc_get_logger()->warning( 'Product feed generation got stuck; reducing the chunk size for future runs.', array( 'option_key' => $option_key, 'previous_chunk_size' => $current, 'chunk_size' => $next, ) ); return $next; } /** * Returns the number of products fetched per database batch. * * @return int The batch size (at least 1). */ private function get_batch_size(): int { /** * Filters the number of products fetched per database query during feed generation. * * This is the granularity within a chunk; see `woocommerce_product_feed_chunk_size` for how many * products each Action Scheduler action processes. * * @param int $batch_size The number of products per database batch. * * @since 11.0.0 */ $batch_size = (int) apply_filters( 'woocommerce_product_feed_batch_size', self::BATCH_SIZE ); return (int) max( 1, $batch_size ); } /** * Updates the cumulative progress fields on the status and refreshes the heartbeat. * * @param array $status The current feed generation status. * @param int $processed The cumulative number of products processed so far. * @param int $total The total number of products to process. * @return array The updated status. */ private function update_progress( array $status, int $processed, int $total ): array { $status['processed'] = $processed; $status['total'] = $total; $status['progress'] = $total > 0 ? round( ( $processed / $total ) * 100, 2 ) : 0; $status['updated_at'] = time(); return $status; } /** * Applies the dynamic field arguments to the product mapper. * * @param array $args The feed generation arguments. * @return void */ private function apply_mapper_args( array $args ): void { if ( isset( $args['_product_fields'] ) && is_string( $args['_product_fields'] ) && '' !== $args['_product_fields'] ) { $this->integration->get_product_mapper()->set_fields( $args['_product_fields'] ); } if ( isset( $args['_variation_fields'] ) && is_string( $args['_variation_fields'] ) && '' !== $args['_variation_fields'] ) { $this->integration->get_product_mapper()->set_variation_fields( $args['_variation_fields'] ); } } /** * Deletes the feed file referenced by a status, if any. * * Completed feeds expose a full path; in-progress chunked feeds only track a file name. * * @param array $status The feed generation status. * @return void */ private function discard_feed( array $status ): void { // A completed feed exposes a full path; an in-progress chunked feed only tracks a file name. // Reduce either to a plain identifier and let ResumableFeedInterface::delete() validate it and confine // the deletion to the feed directory, so a tampered path read back from the option can never escape it. $identifier = ! empty( $status['file_name'] ) ? (string) $status['file_name'] : ( ! empty( $status['path'] ) ? wp_basename( (string) $status['path'] ) : '' ); if ( '' !== $identifier ) { $this->integration->create_feed()->delete( $identifier ); } } /** * Forces a regeneration of the feed. * * @since 10.5.0 * * @param array|null $args The arguments to pass to the action. * @return array The feed generation status. * @throws \Exception When there is a reason why the regeneration cannot be forced. */ public function force_regeneration( ?array $args = null ): array { $option_key = $this->get_option_key( $args ); $status = get_option( $option_key ); // An invalid status (stale, expired, or a stalled in-progress job) always regenerates from // scratch: discard any partial feed and clear the option so the restart starts clean. if ( ! is_array( $status ) || ! $this->validate_status( $status ) ) { if ( is_array( $status ) ) { // A stuck in-progress job adapts its chunk size on a forced rebuild too, the same way an // ordinary poll does, so the rebuild does not re-die at the size that just got it killed. $this->reduce_chunk_size_if_stuck( $status, $option_key ); $this->discard_feed( $status ); delete_option( $option_key ); } return $this->get_status( $args ); } switch ( $status['state'] ?? '' ) { case self::STATE_SCHEDULED: // Generation is already scheduled and should start shortly; leave it be. return $status; case self::STATE_IN_PROGRESS: // A genuinely running job (fresh heartbeat) cannot be interrupted mid-flight. throw new \Exception( 'Feed generation is already in progress and cannot be stopped.' ); case self::STATE_COMPLETED: $this->discard_feed( $status ); delete_option( $option_key ); return $this->get_status( $args ); // A failed job is invalid (see validate_status()), so it never reaches this switch; it is // discarded and regenerated by the early return above. default: throw new \Exception( 'Unknown feed generation state.' ); } } /** * Action scheduler callback for the feed deletion after expiry. * * @since 10.5.0 * * @param string $option_key The option key for the feed generation status. * @param string $path The path to the feed file. * @return void */ public function feed_deletion_action( string $option_key, string $path ) { delete_option( $option_key ); wp_delete_file( $path ); } /** * Returns the option key for the feed generation status. * * @param array|null $args The arguments to pass to the action. * @return string The option key. */ private function get_option_key( ?array $args = null ): string { $normalized_args = $args ?? array(); if ( ! empty( $normalized_args ) ) { ksort( $normalized_args ); } return 'feed_status_' . md5( // WPCS dislikes serialize for security reasons, but it will be hashed immediately. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize serialize( array( 'integration' => $this->integration->get_id(), 'args' => $normalized_args, ) ) ); } /** * Validates the status of the feed generation. * * Makes sure that the file exists for completed jobs, * that scheduled jobs are not stuck, etc. * * @param array $status The status of the feed generation. * @return bool True if the status is valid, false otherwise. */ private function validate_status( array $status ): bool { // A failed job is never served as-is. get_status() surfaces the failure to the client once and // then clears it, so the client can react and its next poll starts a fresh run; force_regeneration() // likewise treats it as invalid and regenerates. Either way it must not validate. if ( self::STATE_FAILED === $status['state'] ) { return false; } // For completed jobs, the file must still exist and not be expired (e.g. manually deleted, or a // cleanup job that failed to clear an expired feed). if ( self::STATE_COMPLETED === $status['state'] ) { if ( ! file_exists( $status['path'] ) ) { return false; } if ( ! isset( $status['completed_at'] ) ) { return false; } if ( $status['completed_at'] + self::FEED_EXPIRY < time() ) { return false; } } /** * Allows the timeout for a feed to remain in `scheduled` state to be changed. Past this point * Action Scheduler is typically stuck and the job is regenerated. * * @param int $stuck_time The stuck time in seconds. * @return int The stuck time in seconds. * @since 10.5.0 */ $scheduled_timeout = apply_filters( 'woocommerce_product_feed_scheduled_timeout', 10 * MINUTE_IN_SECONDS ); if ( self::STATE_SCHEDULED === $status['state'] && ( ! isset( $status['scheduled_at'] ) || time() - $status['scheduled_at'] > $scheduled_timeout ) ) { return false; } // An in-progress job that has not refreshed its heartbeat (`updated_at`, set on start and after // every batch) within the timeout was most likely killed (host timeout or out of memory) before // it could mark itself failed. Treat it as stuck so a new feed can be generated. if ( self::STATE_IN_PROGRESS === $status['state'] ) { $last_activity = $status['updated_at'] ?? $status['scheduled_at'] ?? 0; /** * Allows the heartbeat timeout for an `in_progress` feed to be changed. Past this point the * job is treated as stuck and regenerated. * * The default is kept comfortably larger than the per-batch time budget on purpose. The * heartbeat only refreshes between batches, so the longest gap a healthy job can produce is * roughly one batch (`woocommerce_product_feed_batch_time_limit`). A timeout at or near that * budget would let a single slow-but-valid batch look stuck, and recovery would then discard * the partial the live process is still writing. Deriving it as a multiple (with a floor) * keeps that margin even when the batch budget is raised via its own filter. * * @param int $stuck_time The stuck time in seconds. * @return int The stuck time in seconds. * @since 11.0.0 */ $in_progress_timeout = apply_filters( 'woocommerce_product_feed_in_progress_timeout', max( 15 * MINUTE_IN_SECONDS, 3 * $this->get_batch_time_limit() ) ); if ( time() - $last_activity > $in_progress_timeout ) { return false; } } return true; } }