Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
43f6a52
Remove the unused additional args for ->walk()
RadoslavGeorgiev Oct 28, 2025
a2149a9
Add a static factory to the walker class
RadoslavGeorgiev Oct 28, 2025
509d9e9
Require integrations to define query args while creating walkers
RadoslavGeorgiev Oct 28, 2025
a60b022
Add arguments to the async generator and update actions accordingly.
RadoslavGeorgiev Oct 28, 2025
f20480d
Store arguments in the status option
RadoslavGeorgiev Oct 28, 2025
7de778e
Instead of just creating unique file names, make them unique and priv…
RadoslavGeorgiev Oct 28, 2025
238b92c
Use AS correctly: Switch to as_enqueue_async_action and add groups.
RadoslavGeorgiev Oct 29, 2025
677270a
ActionScheduler work-around
RadoslavGeorgiev Oct 29, 2025
4894f91
Merge branch 'trunk' into wooptp-41-product-feed-allow-custom-pos-par…
RadoslavGeorgiev Oct 29, 2025
46aac14
Add stronger types to the walker and inheritDoc blocks to integrations
RadoslavGeorgiev Oct 29, 2025
e68acbf
Instead of manipulating AS to run async actions, perform the action d…
RadoslavGeorgiev Oct 30, 2025
b5d3223
Simplify action names for AsyncGenerator
RadoslavGeorgiev Oct 30, 2025
5de6e1b
Forward status when forcing regeneration
RadoslavGeorgiev Oct 30, 2025
2bb6087
Prolongate feed expiry
RadoslavGeorgiev Oct 30, 2025
60bcc27
Reorganize feed initiation, make sure that deleted files ignore the `…
RadoslavGeorgiev Oct 30, 2025
796e301
Add further validation to the async generator.
RadoslavGeorgiev Oct 30, 2025
ffd167d
Add initial tests for JsonFileFeed
RadoslavGeorgiev Oct 30, 2025
97c6f1a
Further JsonFileFeed tests
RadoslavGeorgiev Oct 30, 2025
57785d8
Make MemoryManager methods non-static and mockable.
RadoslavGeorgiev Oct 30, 2025
c427b00
Add a test container and implement it for unit tests
RadoslavGeorgiev Oct 30, 2025
2239456
Add tests for the product walker
RadoslavGeorgiev Oct 30, 2025
9036660
Test for memory management
RadoslavGeorgiev Oct 30, 2025
c8f8645
Address CR nitpicks
RadoslavGeorgiev Oct 30, 2025
7123dd1
Sort normalized args
RadoslavGeorgiev Oct 30, 2025
55e5c54
Fix a typo
RadoslavGeorgiev Oct 31, 2025
b6d1f83
Settings tests
RadoslavGeorgiev Oct 31, 2025
db040d2
Fix a couple of mistakenly commited test left-overs
RadoslavGeorgiev Oct 31, 2025
6c055bf
Addressing feedback and adding proper tests for wp_hash for FileFeed
RadoslavGeorgiev Nov 3, 2025
9473691
Fix typos
RadoslavGeorgiev Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/CLI/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public function generate( $args, $assoc_args ) {

// Initialize the feed and walker, set them up.
$feed = $integration->create_feed();
$walker = new ProductWalker( $integration->get_product_mapper(), $integration->get_feed_validator(), $feed );
$walker = ProductWalker::from_integration( $integration, $feed );
$walker->set_batch_size( $batch_size );
$walker->add_time_limit( $timeout );

Expand Down
97 changes: 67 additions & 30 deletions src/Feed/ProductWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Automattic\WooCommerce\ProductFeedForOpenAI\Feed;

use Automattic\WooCommerce\ProductFeedForOpenAI\Integrations\IntegrationInterface;
use Automattic\WooCommerce\ProductFeedForOpenAI\Utils\MemoryManager;

if ( ! defined( 'ABSPATH' ) ) {
Expand Down Expand Up @@ -54,6 +55,13 @@ class ProductWalker {
*/
private int $time_limit = 0;

/**
* The query arguments to apply to the product query.
*
* @var array
*/
private $query_args;

/**
* Class constructor.
*
Expand All @@ -62,15 +70,67 @@ class ProductWalker {
* @param ProductMapperInterface $mapper The product mapper.
* @param FeedValidatorInterface $validator The feed validator.
* @param FeedInterface $feed The feed.
* @param array $query_args The query arguments.
*/
public function __construct(
private function __construct(
ProductMapperInterface $mapper,
FeedValidatorInterface $validator,
FeedInterface $feed
FeedInterface $feed,
array $query_args
) {
$this->mapper = $mapper;
$this->validator = $validator;
$this->feed = $feed;
$this->mapper = $mapper;
$this->validator = $validator;
$this->feed = $feed;
$this->query_args = $query_args;
}

/**
* Creates a new instance of the ProductWalker class based on an integration.
*
* The walker will mostly be set up based on the integration.
* The feed is provided externally, as it might be based on the context (CLI, REST, Action Scheduler, etc.).
*
* @param IntegrationInterface $integration The integration.
* @param FeedInterface $feed The feed.
* @return self The ProductWalker instance.
*/
public static function from_integration(
IntegrationInterface $integration,
FeedInterface $feed
): self {
$query_args = array_merge(
[
'status' => [ 'publish' ],
'return' => 'objects',
],
$integration->get_product_feed_query_args()
);

/**
* Allows the base arguments for querying products for product feeds to be changed.
*
* Variable products are not included by default, as their variations will be included.
*
* @since 0.1.0
*
* @param array $query_args The arguments to pass to wc_get_products().
* @param IntegrationInterface $integration The integration that the query belongs to.
* @return array
*/
$query_args = apply_filters(
'wpfoai_product_feed_args',
$query_args,
$integration
);

$instance = new self(
$integration->get_product_mapper(),
$integration->get_feed_validator(),
$feed,
$query_args
);

return $instance;
}

/**
Expand Down Expand Up @@ -99,42 +159,19 @@ public function add_time_limit( int $time_limit ): self {
* Walks through all products.
*
* @param callable $callback The callback to call after each batch of products is processed.
* @param array $additional_args Optional. Additional arguments to merge into the base query args.
* @return int The total number of products processed.
*/
public function walk( ?callable $callback = null, array $additional_args = [] ): int {
public function walk( ?callable $callback = null ): int {
$progress = null;

/**
* Allows the base arguments for querying products for product feeds to be changed.
*
* Variable products are not included by default, as their variations will be included.
*
* @since 0.1.0
*
* @param array $args The arguments to pass to wc_get_products().
* @return array
*/
$args = apply_filters(
'wpfoai_product_feed_args',
array_merge(
[
'status' => [ 'publish' ],
'type' => [ 'simple', 'variation' ],
'return' => 'objects',
],
$additional_args
)
);

// Instruct the feed to start.
$this->feed->start();

// Check how much memory is available at first.
$initial_available_memory = MemoryManager::get_available_memory();

do {
$result = $this->iterate( $args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page );
$result = $this->iterate( $this->query_args, $progress ? $progress->processed_batches + 1 : 1, $this->per_page );
$iterated = count( $result->products );

// Only done when the progress is not set. Will be modified otherwise.
Expand Down
8 changes: 8 additions & 0 deletions src/Integrations/IntegrationInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ public function activate(): void;
*/
public function deactivate(): void;

/**
* Get the query arguments for the product feed.
*
* @see wc_get_products()
* @return array The query arguments.
*/
public function get_product_feed_query_args(): array;

/**
* Create a feed that is to be populated.
*
Expand Down
11 changes: 11 additions & 0 deletions src/Integrations/OpenAi/OpenAiIntegration.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ public function get_id(): string {
return 'openai';
}

/**
* Get the query arguments for the product feed.
*
* @return array The query arguments.
*/
public function get_product_feed_query_args(): array {
return [
'type' => [ 'simple', 'variation' ],
];
}

/**
* Create a feed that is to be populated.
*
Expand Down
6 changes: 1 addition & 5 deletions src/Integrations/OpenAi/ScheduledActionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,7 @@ public function scheduled_push(): void {
}

$feed = $this->openai_integration->create_feed();
$walker = new ProductWalker(
$this->openai_integration->get_product_mapper(),
$this->openai_integration->get_feed_validator(),
$feed
);
$walker = ProductWalker::from_integration( $this->openai_integration, $feed );
$walker->walk();

$response = $delivery_method->deliver( $feed );
Expand Down
7 changes: 6 additions & 1 deletion src/Integrations/POSCatalog/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ public function is_authorized() {
public function generate_feed( WP_REST_Request $request ) {
$generator = $this->container->get( AsyncGenerator::class );
try {
$response = $request->get_param( 'force' ) ? $generator->force_regeneration() : $generator->get_status();
$params = [];
$response = $request->get_param( 'force' )
? $generator->force_regeneration( $params )
: $generator->get_status( $params );

// Remove sensitive data from the response.
if ( isset( $response['action_id'] ) ) {
unset( $response['action_id'] );
}
Expand Down
89 changes: 56 additions & 33 deletions src/Integrations/POSCatalog/AsyncGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,6 @@ final class AsyncGenerator {
*/
const FEED_DELETION_ACTION = 'wpfoai_pos_catalog_feed_deletion';

/**
* The option key for the feed generation status.
*
* @var string
*/
const OPTION_KEY = 'pos_feed_status';

/**
* Feed expiry time, once completed.
* If the feed is not downloaded within this timeframe, a new one will need to be generated.
Expand Down Expand Up @@ -79,36 +72,45 @@ public function init( POSIntegration $integration ) {
*/
public function register_hooks(): void {
add_action( self::FEED_GENERATION_ACTION, [ $this, 'feed_generation_action' ] );
add_action( self::FEED_DELETION_ACTION, [ $this, 'feed_deletion_action' ] );
add_action( self::FEED_DELETION_ACTION, [ $this, 'feed_deletion_action' ], 10, 2 );
}

/**
* Returns the current feed generation status.
* Initiates one if not already running.
*
* @return array The feed generation status.
* @param array|null $args The arguments to pass to the action.
* @return array The feed generation status.
*/
public function get_status(): array {
$status = get_option( self::OPTION_KEY );
public function get_status( ?array $args = null ): array {
// Determine the option key based on the integration ID and arguments.
$option_key = $this->get_option_key( $args );

$status = get_option( $option_key );

if ( false === $status ) {
// Clear all previous actions to avoid race conditions.
as_unschedule_all_actions( self::FEED_GENERATION_ACTION );
as_unschedule_all_actions( self::FEED_GENERATION_ACTION, [ 'option_key' => $option_key ] );

// Add a bit of delay to avoid race conditions.
$delay = 10;
$action_id = as_schedule_single_action( time() + $delay, self::FEED_GENERATION_ACTION, [] );
$action_id = as_schedule_single_action(
time() + $delay,
self::FEED_GENERATION_ACTION,
[ 'option_key' => $option_key ]
);

$status = [
'action_id' => $action_id,
'state' => self::STATE_SCHEDULED,
'progress' => 0,
'processed' => 0,
'total' => -1,
'args' => $args ?? [],
];

update_option(
self::OPTION_KEY,
$option_key,
$status
);
}
Expand All @@ -119,55 +121,57 @@ public function get_status(): array {
/**
* Action scheduler callback for the feed generation.
*
* @param string $option_key The option key for the feed generation status.
* @return void
*/
public function feed_generation_action() {
$status = get_option( self::OPTION_KEY );
public function feed_generation_action( string $option_key ) {
$status = get_option( $option_key );

if ( ! is_array( $status ) || ! isset( $status['state'] ) || self::STATE_SCHEDULED !== $status['state'] ) {
wc_get_logger()->error( 'Invalid feed generation status', [ 'status' => $status ] );
return;
}

$status['state'] = self::STATE_IN_PROGRESS;
update_option( self::OPTION_KEY, $status );
update_option( $option_key, $status );

$feed = $this->integration->create_feed();
$walker = new ProductWalker(
$this->integration->get_product_mapper(),
$this->integration->get_feed_validator(),
$feed
);
$walker = ProductWalker::from_integration( $this->integration, $feed );

$walker->walk(
function ( WalkerProgress $progress ) use ( &$status ) {
function ( WalkerProgress $progress ) use ( &$status, $option_key ) {
$status = $this->update_feed_progress( $status, $progress );
update_option( self::OPTION_KEY, $status );
update_option( $option_key, $status );
}
);

// Store the final details.
$status['state'] = self::STATE_COMPLETED;
$status['url'] = $feed->get_file_url();
$status['path'] = $feed->get_file_path();
update_option( self::OPTION_KEY, $status );
update_option( $option_key, $status );

// Schedule another action to delete the file after the expiry time.
as_schedule_single_action(
time() + self::FEED_EXPIRY,
self::FEED_DELETION_ACTION,
[ 'path' => $feed->get_file_path() ]
[
$option_key,
$feed->get_file_path(),
]
);
}

/**
* Forces a regeneration of the feed.
*
* @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 {
$status = get_option( self::OPTION_KEY );
public function force_regeneration( ?array $args = null ): array {
$option_key = $this->get_option_key( $args );
$status = get_option( $option_key );

// If there is no option, there is nothing to force.
if ( false === $status ) {
Expand All @@ -186,7 +190,7 @@ public function force_regeneration(): array {
case self::STATE_COMPLETED:
// Delete the existing file, clear the option and let generation start again.
wp_delete_file( $status['path'] );
delete_option( self::OPTION_KEY );
delete_option( $option_key );
return $this->get_status();

default:
Expand All @@ -197,13 +201,32 @@ public function force_regeneration(): array {
/**
* Action scheduler callback for the feed deletion after expiry.
*
* @param array $args The arguments passed to the action.
* @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( array $args ) {
$path = $args['path'];
public function feed_deletion_action( string $option_key, string $path ) {
delete_option( $option_key );
wp_delete_file( $path );
delete_option( self::OPTION_KEY );
}

/**
* 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 {
return 'pos_feed_status_' . md5(
// WPCS dislikes serialize for security reasons, but it will be hashed immediately.
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
serialize(
[
'integration' => $this->integration->get_id(),
'args' => $args,
]
)
);
}

/**
Expand Down
Loading