Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog

AsyncGenerator::feed_generation_actionpublicWC 10.5.0

Action scheduler callback for the feed generation.

Method of the class: AsyncGenerator{}

No Hooks.

Returns

null. Nothing (null).

Usage

$AsyncGenerator = new AsyncGenerator();
$AsyncGenerator->feed_generation_action( $option_key );
$option_key(string) (required)
The option key for the feed generation status.

Changelog

Since 10.5.0 Introduced.

AsyncGenerator::feed_generation_action() code WC 11.0.0

public function feed_generation_action( string $option_key ) {
	$status = get_option( $option_key );

	// Only a scheduled (first chunk) or in-progress (continuation) job should be processed here.
	if ( ! is_array( $status ) || ! in_array( $status['state'] ?? '', array( self::STATE_SCHEDULED, self::STATE_IN_PROGRESS ), true ) ) {
		wc_get_logger()->error( 'Invalid feed generation status', array( 'status' => $status ) );
		return;
	}

	$is_first_chunk = self::STATE_SCHEDULED === $status['state'];

	// A continuation must know which feed file it is appending to. If it doesn't, the status is
	// corrupt; bail and let the heartbeat-based recovery restart generation from scratch.
	if ( ! $is_first_chunk && empty( $status['file_name'] ) ) {
		wc_get_logger()->error( 'Invalid feed generation continuation status', array( 'status' => $status ) );
		return;
	}

	$feed = null;
	try {
		$this->raise_resource_limits();

		$feed = $this->integration->create_feed();

		if ( $is_first_chunk ) {
			$status['file_name']       = $feed->open();
			$status['page']            = 1;
			$status['processed']       = 0;
			$status['entries_written'] = 0;
		} else {
			$feed->open( (string) $status['file_name'], (int) ( $status['entries_written'] ?? 0 ) );
		}

		// Only now that the feed lock is held (acquired by open()) do we claim the job as in-progress
		// and refresh the heartbeat. Doing it earlier would rewrite the status — and bump updated_at —
		// even for a run that immediately steps aside on FeedLockException, contradicting that path's
		// "leave the status untouched" intent and refreshing the heartbeat of a job it never wrote.
		$status['state']      = self::STATE_IN_PROGRESS;
		$status['updated_at'] = time();
		update_option( $option_key, $status );

		$walker = ProductWalker::from_integration( $this->integration, $feed );
		$walker->set_batch_size( $this->get_batch_size() );
		$walker->add_time_limit( $this->get_batch_time_limit() );

		$this->apply_mapper_args( $status['args'] ?? array() );

		$start_page     = max( 1, (int) ( $status['page'] ?? 1 ) );
		$base_processed = (int) ( $status['processed'] ?? 0 );
		$progress       = $walker->walk_batches(
			function ( WalkerProgress $progress ) use ( &$status, $option_key, $base_processed ) {
				// Refresh progress and the heartbeat after every batch, so polling sees smooth progress
				// within a chunk rather than a single jump at the chunk boundary.
				$status = $this->update_progress( $status, $base_processed + $progress->processed_items, $progress->total_count );
				update_option( $option_key, $status );
			},
			$start_page,
			$this->get_chunk_batch_count( $option_key )
		);

		// The feed's entry count is already cumulative across chunks (open() seeds it with the running
		// total when resuming), so store it as-is rather than adding to the previous total.
		$status                    = $this->update_progress( $status, $base_processed + $progress->processed_items, $progress->total_count );
		$status['entries_written'] = $feed->get_entry_count();
		$status['page']            = $start_page + $progress->processed_batches;

		$is_complete = $progress->total_batch_count <= 0 || (int) $status['page'] > $progress->total_batch_count;

		if ( $is_complete ) {
			$feed->end();

			$status['state']        = self::STATE_COMPLETED;
			$status['progress']     = 100;
			$status['url']          = $feed->get_file_url();
			$status['path']         = $feed->get_file_path();
			$status['completed_at'] = time();
			update_option( $option_key, $status );

			// Schedule deletion of the file after the expiry time.
			as_schedule_single_action(
				time() + self::FEED_EXPIRY,
				self::FEED_DELETION_ACTION,
				array( $option_key, $feed->get_file_path() ),
				'woo-product-feed',
				false
			);
		} else {
			$feed->flush();
			update_option( $option_key, $status );
			$this->schedule_generation_action( $option_key );
		}
	} catch ( FeedLockException $e ) {
		// Another process already holds the feed file lock and is actively writing it, so this run
		// is a redundant duplicate (e.g. Action Scheduler re-ran a slow chunk while the original was
		// still in flight). Step aside and leave the status untouched: the lock holder is making
		// progress, so marking the job failed here would report a failure for a healthy generation
		// and let the next poll discard the partial file the holder is still writing. Release only
		// this run's own handle (which never acquired the lock, so closing it cannot free the holder's).
		if ( $feed instanceof ResumableFeedInterface ) {
			$feed->flush();
		}
		return;
	} catch ( \Throwable $e ) {
		wc_get_logger()->error(
			'Feed generation failed',
			array(
				'error'      => $e->getMessage(),
				'option_key' => $option_key,
			)
		);

		// Release the file handle, if any, so it is not left dangling.
		if ( $feed instanceof ResumableFeedInterface ) {
			$feed->flush();
		}

		$status['state']     = self::STATE_FAILED;
		$status['error']     = $e->getMessage();
		$status['failed_at'] = time();
		update_option( $option_key, $status );
	}
}