n-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; } }