Manual and scheduled sync previously processed every WooCommerce product inline in a single PHP request/AJAX call, with several sequential BC API calls per product. At real catalog sizes this exceeds PHP's max_execution_time and the proxy's read timeout, surfacing as a 502 Bad Gateway on "Sync Now". Adds WBC_Batch_Sync, which queues the catalog in chunks of 25 via Action Scheduler (bundled with WooCommerce) and processes them asynchronously. The manual sync AJAX call now returns immediately after queuing, and the admin UI polls for progress instead of waiting on one long request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
7.9 KiB
PHP
230 lines
7.9 KiB
PHP
<?php
|
|
/**
|
|
* Background batch orchestration for product sync
|
|
*
|
|
* Splits the WooCommerce-first product sync into small chunks processed via
|
|
* Action Scheduler (bundled with WooCommerce), so neither the "Sync Now"
|
|
* AJAX request nor the WP-Cron event has to process the entire catalog in a
|
|
* single PHP execution. At real catalog sizes (tens of thousands of SKUs)
|
|
* that single-request approach exceeds PHP's max_execution_time and the
|
|
* webserver/proxy's read timeout, surfacing as a 502 Bad Gateway.
|
|
*
|
|
* @package WooBusinessCentral
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class WBC_Batch_Sync
|
|
*/
|
|
class WBC_Batch_Sync {
|
|
|
|
/**
|
|
* Number of products processed per background action
|
|
*/
|
|
const BATCH_SIZE = 25;
|
|
|
|
/**
|
|
* Option name storing the current/last run's progress
|
|
*/
|
|
const RUN_OPTION = 'wbc_sync_run';
|
|
|
|
/**
|
|
* Transient used as a simple "a sync is already running" guard
|
|
*/
|
|
const LOCK_TRANSIENT = 'wbc_sync_lock';
|
|
|
|
/**
|
|
* Action Scheduler hook processed per batch
|
|
*/
|
|
const ACTION_HOOK = 'wbc_process_sync_batch';
|
|
|
|
/**
|
|
* Action Scheduler group name
|
|
*/
|
|
const ACTION_GROUP = 'wbc_product_sync';
|
|
|
|
/**
|
|
* Register the Action Scheduler batch handler
|
|
*/
|
|
public function register_hooks() {
|
|
add_action( self::ACTION_HOOK, array( $this, 'process_batch' ), 10, 1 );
|
|
}
|
|
|
|
/**
|
|
* Kick off a full catalog sync in the background
|
|
*
|
|
* Gathers WooCommerce product IDs with a SKU (ID-only query - never
|
|
* loads full product objects for the whole catalog into memory), splits
|
|
* them into batches, and enqueues one background action per batch.
|
|
* Returns immediately; actual work happens asynchronously.
|
|
*
|
|
* @return array Result with 'success' and 'message'.
|
|
*/
|
|
public static function queue_full_sync() {
|
|
if ( get_transient( self::LOCK_TRANSIENT ) ) {
|
|
$run = get_option( self::RUN_OPTION, array() );
|
|
return array(
|
|
'success' => true,
|
|
'started' => false,
|
|
'message' => __( 'A sync is already running.', 'woo-business-central' ),
|
|
'run' => $run,
|
|
);
|
|
}
|
|
|
|
if ( get_option( 'wbc_enable_stock_sync', 'yes' ) !== 'yes' && get_option( 'wbc_enable_price_sync', 'yes' ) !== 'yes' ) {
|
|
WBC_Logger::info( 'BatchSync', 'Sync skipped - both stock and price sync are disabled' );
|
|
return array(
|
|
'success' => true,
|
|
'message' => __( 'Sync skipped - both stock and price sync are disabled.', 'woo-business-central' ),
|
|
);
|
|
}
|
|
|
|
if ( ! WBC_OAuth::is_configured() ) {
|
|
WBC_Logger::error( 'BatchSync', 'Sync failed - API credentials not configured' );
|
|
return array(
|
|
'success' => false,
|
|
'message' => __( 'Sync failed - API credentials not configured.', 'woo-business-central' ),
|
|
);
|
|
}
|
|
|
|
if ( ! function_exists( 'as_enqueue_async_action' ) ) {
|
|
WBC_Logger::error( 'BatchSync', 'Action Scheduler not available (ships with WooCommerce)' );
|
|
return array(
|
|
'success' => false,
|
|
'message' => __( 'Background task runner is not available.', 'woo-business-central' ),
|
|
);
|
|
}
|
|
|
|
// ID-only query with a meta filter, so 60k+ catalogs don't get loaded
|
|
// as full WC_Product objects just to check for a SKU.
|
|
$product_ids = wc_get_products( array(
|
|
'limit' => -1,
|
|
'status' => 'publish',
|
|
'return' => 'ids',
|
|
'meta_query' => array( // phpcs:ignore WooCommerce.Commenting -- deliberate, avoids full-object load at scale.
|
|
array(
|
|
'key' => '_sku',
|
|
'value' => '',
|
|
'compare' => '!=',
|
|
),
|
|
),
|
|
) );
|
|
|
|
if ( empty( $product_ids ) ) {
|
|
WBC_Logger::info( 'BatchSync', 'No WooCommerce products with SKU found' );
|
|
return array(
|
|
'success' => true,
|
|
'message' => __( 'No WooCommerce products with SKU found.', 'woo-business-central' ),
|
|
);
|
|
}
|
|
|
|
$run_id = uniqid( 'wbc_', true );
|
|
$batches = array_chunk( $product_ids, self::BATCH_SIZE );
|
|
|
|
update_option( self::RUN_OPTION, array(
|
|
'run_id' => $run_id,
|
|
'status' => 'running',
|
|
'total' => count( $product_ids ),
|
|
'total_batches' => count( $batches ),
|
|
'batches_done' => 0,
|
|
'success' => 0,
|
|
'failed' => 0,
|
|
'skipped' => 0,
|
|
'started_at' => current_time( 'mysql' ),
|
|
'finished_at' => '',
|
|
), false );
|
|
|
|
// Safety-net expiry in case a batch throws and never reports back;
|
|
// prevents the lock from wedging the sync button forever.
|
|
set_transient( self::LOCK_TRANSIENT, $run_id, 4 * HOUR_IN_SECONDS );
|
|
|
|
foreach ( $batches as $batch ) {
|
|
as_enqueue_async_action( self::ACTION_HOOK, array(
|
|
'run_id' => $run_id,
|
|
'product_ids' => $batch,
|
|
), self::ACTION_GROUP );
|
|
}
|
|
|
|
WBC_Logger::info( 'BatchSync', 'Queued background sync', array(
|
|
'run_id' => $run_id,
|
|
'total_products' => count( $product_ids ),
|
|
'total_batches' => count( $batches ),
|
|
) );
|
|
|
|
return array(
|
|
'success' => true,
|
|
'started' => true,
|
|
'message' => sprintf(
|
|
/* translators: %d: number of products */
|
|
__( 'Sync started in the background for %d product(s).', 'woo-business-central' ),
|
|
count( $product_ids )
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Process a single batch (called by Action Scheduler)
|
|
*
|
|
* @param array $args Action args: run_id, product_ids.
|
|
*/
|
|
public function process_batch( $args ) {
|
|
$run_id = $args['run_id'] ?? '';
|
|
$product_ids = $args['product_ids'] ?? array();
|
|
|
|
$run = get_option( self::RUN_OPTION, array() );
|
|
|
|
// A newer sync superseded this run (e.g. re-triggered manually) - drop it.
|
|
if ( empty( $run ) || ( $run['run_id'] ?? '' ) !== $run_id ) {
|
|
return;
|
|
}
|
|
|
|
$sync = new WBC_Product_Sync();
|
|
$sync->run_sync_for_ids( $product_ids );
|
|
$batch_results = $sync->get_results();
|
|
|
|
// Progress counters are best-effort: Action Scheduler can run batches
|
|
// concurrently, and update_option() read-modify-write isn't atomic.
|
|
// Good enough for a progress indicator; not used for anything
|
|
// correctness-critical.
|
|
$run = get_option( self::RUN_OPTION, $run );
|
|
if ( ( $run['run_id'] ?? '' ) !== $run_id ) {
|
|
return;
|
|
}
|
|
|
|
$run['batches_done'] = (int) ( $run['batches_done'] ?? 0 ) + 1;
|
|
$run['success'] = (int) ( $run['success'] ?? 0 ) + $batch_results['success'];
|
|
$run['failed'] = (int) ( $run['failed'] ?? 0 ) + $batch_results['failed'];
|
|
$run['skipped'] = (int) ( $run['skipped'] ?? 0 ) + $batch_results['skipped'];
|
|
|
|
if ( $run['batches_done'] >= $run['total_batches'] ) {
|
|
$run['status'] = 'completed';
|
|
$run['finished_at'] = current_time( 'mysql' );
|
|
WBC_Cron::update_last_sync_time();
|
|
delete_transient( self::LOCK_TRANSIENT );
|
|
|
|
WBC_Logger::info( 'BatchSync', 'Background sync completed', $run );
|
|
}
|
|
|
|
update_option( self::RUN_OPTION, $run, false );
|
|
}
|
|
|
|
/**
|
|
* Get the current/last sync run status
|
|
*
|
|
* @return array Run status, or array('status' => 'none') if never run.
|
|
*/
|
|
public static function get_status() {
|
|
$run = get_option( self::RUN_OPTION, array() );
|
|
|
|
if ( empty( $run ) ) {
|
|
return array( 'status' => 'none' );
|
|
}
|
|
|
|
return $run;
|
|
}
|
|
}
|