From 0114d3b5d64879464db519e410ec842dd8c354e7 Mon Sep 17 00:00:00 2001 From: Nestor Soriano Date: Tue, 8 Mar 2022 16:54:45 +0100 Subject: [PATCH] Infrastructure for the sync process - Update settings UI - Start sync via scheduled actions when sync is enabled - Auto-switch authoritative table on sync finished if so configured - Disable auto-switch if sync is disabled - Show initial and current count of orders pending sync in settings UI --- .../admin/class-wc-admin-settings.php | 9 + .../Orders/CustomOrdersTableController.php | 217 ++++++++++++++++-- .../DataStores/Orders/DataSynchronizer.php | 127 +++++++++- 3 files changed, 331 insertions(+), 22 deletions(-) diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php index 69506066749..48bbe0e6e2a 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php @@ -7,6 +7,7 @@ */ use Automattic\Jetpack\Constants; +use Automattic\WooCommerce\Utilities\ArrayUtil; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -273,6 +274,12 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) : } break; + case 'info': + echo ''; + echo wp_kses_post( wpautop( wptexturize( $value['text'] ) ) ); + echo ''; + break; + // Section Ends. case 'sectionend': if ( ! empty( $value['id'] ) ) { @@ -417,6 +424,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) : // Radio inputs. case 'radio': $option_value = $value['value']; + $disabled_values = ArrayUtil::get_value_or_default($value, 'disabled', array()); ?> @@ -435,6 +443,7 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) : name="" value="" type="radio" + style="" class="" diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php index aaaa777505e..338a6e17608 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php @@ -92,6 +92,38 @@ class CustomOrdersTableController { 999, 2 ); + + add_filter( + 'updated_option', + function( $option, $old_value, $value ) { + $this->process_updated_option( $option, $old_value, $value ); + }, + 999, + 3 + ); + + add_filter( + 'pre_update_option', + function( $value, $option, $old_value ) { + return $this->process_pre_update_option( $option, $old_value, $value ); + }, + 999, + 3 + ); + + add_filter( + DataSynchronizer::PENDING_SYNCHRONIZATION_FINISHED_ACTION, + function() { + $this->process_sync_finished(); + } + ); + + add_action( + 'woocommerce_update_options_advanced_custom_data_stores', + function() { + $this->process_options_updated(); + } + ); } /** @@ -213,6 +245,7 @@ class CustomOrdersTableController { } $this->data_synchronizer->create_database_tables(); + update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' ); } /** @@ -258,40 +291,182 @@ class CustomOrdersTableController { return $settings; } - $title_item = array( - 'title' => __( 'Custom orders tables', 'woocommerce' ), - 'type' => 'title', - 'desc' => sprintf( - /* translators: %1$s = tag, %2$s = tag. */ - __( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ), - '', - '' - ), - ); - if ( $this->data_synchronizer->check_orders_table_exists() ) { - $settings[] = $title_item; + $settings[] = array( + 'title' => __( 'Custom orders tables', 'woocommerce' ), + 'type' => 'title', + 'id' => 'cot-title', + 'desc' => sprintf( + /* translators: %1$s = tag, %2$s = tag. */ + __( '%1$sWARNING:%2$s This feature is currently under development and may cause database instability. For contributors only.', 'woocommerce' ), + '', + '' + ), + ); + + $sync_status = $this->data_synchronizer->get_sync_status(); + $sync_is_pending = 0 !== $sync_status['current_pending_count']; $settings[] = array( - 'title' => __( 'Enable tables usage', 'woocommerce' ), - 'desc' => __( 'Use the custom orders tables as the main orders data store.', 'woocommerce' ), + 'title' => __( 'Data store for orders', 'woocommerce' ), 'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'default' => 'no', - 'type' => 'checkbox', + 'type' => 'radio', + 'options' => array( + 'yes' => __( 'Use the WooCommerce orders tables', 'woocommerce' ), + 'no' => __( 'Use the WordPress posts table', 'woocommerce' ), + ), 'checkboxgroup' => 'start', + 'disabled' => $sync_is_pending ? array( 'yes', 'no' ) : array(), ); + + if ( $sync_is_pending ) { + $initial_pending_count = $sync_status['initial_pending_count']; + if ( $initial_pending_count ) { + $text = sprintf( + /* translators: %1$s=current number of orders pending sync, %2$s=initial number of orders pending sync */ + __( 'There are %1$s orders (out of a total of %2$s) pending sync!', 'woocommerce' ), + $sync_status['current_pending_count'], + $initial_pending_count + ); + } else { + $text = sprintf( + /* translators: %1$s=current number of orders pending sync */ + __( 'There are %1$s orders pending sync!', 'woocommerce' ), + $sync_status['current_pending_count'] + ); + } + + if ( $this->data_synchronizer->pending_data_sync_is_in_progress() ) { + $text .= __( '
Syncrhonization for these orders is currently in progress.', 'woocommerce' ); + } + + $text .= __( "
The authoritative table can't be changed until sync completes.", 'woocommerce' ); + + $settings[] = array( + 'type' => 'info', + 'id' => 'cot-out-of-sync-warning', + 'css' => 'color: #C00000', + 'text' => $text, + ); + } + + $settings[] = array( + 'desc' => __( 'Keep the posts table and the orders tables synchronized', 'woocommerce' ), + 'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, + 'type' => 'checkbox', + ); + + if ( $sync_is_pending ) { + if ( $this->data_synchronizer->data_sync_is_enabled() ) { + $message = $this->custom_orders_table_usage_is_enabled() ? + __( 'Switch to using the posts table as the authoritative data store for orders when sync finishes', 'woocommerce' ) : + __( 'Switch to using the orders table as the authoritative data store for orders when sync finishes', 'woocommerce' ); + $settings[] = array( + 'desc' => $message, + 'id' => DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, + 'type' => 'checkbox', + ); + } + } } else { - $title_item['desc'] = sprintf( - /* translators: %1$s = tag, %2$s = tag. */ - __( 'Create the tables first by going to %1$sWooCommerce > Status > Tools%2$s and running %1$sCreate the custom orders tables%2$s.', 'woocommerce' ), - '', - '' + $settings[] = array( + 'title' => __( 'Custom orders tables', 'woocommerce' ), + 'type' => 'title', + 'desc' => sprintf( + /* translators: %1$s = tag, %2$s = tag. */ + __( 'Create the tables first by going to %1$sWooCommerce > Status > Tools%2$s and running %1$sCreate the custom orders tables%2$s.', 'woocommerce' ), + '', + '' + ), ); - $settings[] = $title_item; } $settings[] = array( 'type' => 'sectionend' ); return $settings; } + + /** + * Handler for the individual setting updated hook. + * + * @param string $option Setting name. + * @param mixed $old_value Old value of the setting. + * @param mixed $value New value of the setting. + */ + private function process_updated_option( $option, $old_value, $value ) { + if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) { + $this->data_synchronizer->cleanup_synchronization_state(); + } + } + + /** + * Handler for the setting pre-update hook. + * We use it to verify that authoritative orders table switch doesn't happen while sync is pending. + * + * @param string $option Setting name. + * @param mixed $old_value Old value of the setting. + * @param mixed $value New value of the setting. + * + * @throws \Exception Attempt to change the authoritative orders table while orders sync is pending. + */ + private function process_pre_update_option( $option, $old_value, $value ) { + if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) { + return $value; + } + + $sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count(); + if ( $sync_is_pending ) { + throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" ); + } + + return $value; + } + + /** + * Handler for the synchronization finished hook. + * Here we switch the authoritative table if needed. + */ + private function process_sync_finished() { + if ( $this->auto_flip_authoritative_table_enabled() ) { + return; + } + + update_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' ); + + if ( $this->custom_orders_table_usage_is_enabled() ) { + update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' ); + } else { + update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'yes' ); + } + } + + /** + * Is the automatic authoritative table switch setting set? + * + * @return bool + */ + private function auto_flip_authoritative_table_enabled(): bool { + return 'yes' === get_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ); + } + + /** + * Handler for the all settings updated hook. + */ + private function process_options_updated() { + $data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled(); + + // Disabling the sync implies disabling the automatic authoritative table switch too. + if ( ! $data_sync_is_enabled && $this->auto_flip_authoritative_table_enabled() ) { + update_option( DataSynchronizer::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION, 'no' ); + } + + // Enabling the sync implies starting it too, if needed. + // We do this check here, and not in process_pre_update_option, so that if for some reason + // the setting is enabled but no sync is in process, sync will start by just saving the + // settings even without modifying them. + if ( $data_sync_is_enabled && ! $this->data_synchronizer->pending_data_sync_is_in_progress() ) { + $this->data_synchronizer->start_synchronizing_pending_orders(); + } + } } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php index 19894cfcb18..579a419e43b 100644 --- a/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/DataSynchronizer.php @@ -17,6 +17,13 @@ defined( 'ABSPATH' ) || exit; */ class DataSynchronizer { + const ORDERS_DATA_SYNC_ENABLED_OPTION = 'woocommerce_custom_orders_table_data_sync_enabled'; + const INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION = 'woocommerce_initial_orders_pending_sync_count'; + const AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION = 'woocommerce_auto_flip_authoritative_table_roles'; + const PENDING_SYNC_IS_IN_PROGRESS_OPTION = 'woocommerce_custom_orders_table_pending_sync_in_progress'; + const ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK = 'woocommerce_run_orders_sync_callback'; + const PENDING_SYNCHRONIZATION_FINISHED_ACTION = 'woocommerce_orders_sync_finished'; + /** * The data store object to use. * @@ -31,7 +38,17 @@ class DataSynchronizer { */ private $database_util; - // TODO: Add a constructor to handle hooks as appropriate. + /** + * Class constructor. + */ + public function __construct() { + add_action( + self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK, + function() { + $this->do_pending_orders_synchronization(); + } + ); + } /** * Class initialization, invoked by the DI container. @@ -74,5 +91,113 @@ class DataSynchronizer { } } + /** + * Is the data sync between old and new tables currently enabled? + * + * @return bool + */ + public function data_sync_is_enabled(): bool { + return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION ); + } + /** + * Is a sync process currently in progress? + * + * @return bool + */ + public function pending_data_sync_is_in_progress(): bool { + return 'yes' === get_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION ); + } + + /** + * Get the current sync process status. + * The information is meaningful only if pending_data_sync_is_in_progress return true. + * + * @return array + */ + public function get_sync_status() { + return array( + 'initial_pending_count' => (int) get_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, 0 ), + 'current_pending_count' => $this->get_current_orders_pending_sync_count(), + 'auto_flip' => 'yes' === get_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ), + 'sync_in_progress' => $this->pending_data_sync_is_in_progress(), + ); + } + + /** + * Calculate how many orders need to be synchronized currently. + */ + public function get_current_orders_pending_sync_count(): int { + // TODO: get this value by querying the database. + return get_option( 'woocommerce_fake_orders_pending_sync_count', 0 ); + } + + /** + * Start an orders synchronization process. + * This will setup the appropriate status information and schedule the first synchronization batch. + */ + public function start_synchronizing_pending_orders() { + $initial_pending_count = $this->get_current_orders_pending_sync_count(); + if ( 0 === $initial_pending_count ) { + return; + } + + update_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION, $initial_pending_count ); + + $queue = WC()->get_instance_of( \WC_Queue::class ); + $queue->cancel_all( self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK ); + + update_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION, 'yes' ); + $this->schedule_pending_orders_synchronization(); + } + + /** + * Schedule the next orders synchronization batch. + */ + private function schedule_pending_orders_synchronization() { + $queue = WC()->get_instance_of( \WC_Queue::class ); + $queue->schedule_single( + WC()->call_function( 'time' ) + 1, + self::ORDERS_SYNC_SCHEDULED_ACTION_CALLBACK, + array(), + 'woocommerce-db-updates' + ); + } + + /** + * Run one orders synchronization batch. + */ + private function do_pending_orders_synchronization() { + if ( ! $this->pending_data_sync_is_in_progress() ) { + return; + } + + // TODO: Syncrhonize a batch of orders. + $fake_count = (int) get_option( 'woocommerce_fake_orders_pending_sync_count', 0 ); + if ( $fake_count > 0 ) { + update_option( 'woocommerce_fake_orders_pending_sync_count', $fake_count - 1 ); + } + + if ( 0 === $this->get_current_orders_pending_sync_count() ) { + $this->cleanup_synchronization_state(); + + /** + * Hook to signal that the orders tables synchronization process has finised (nothing left to synchronize). + */ + do_action( self::PENDING_SYNCHRONIZATION_FINISHED_ACTION ); + } else { + $this->schedule_pending_orders_synchronization(); + } + } + + /** + * Cleanup all the synchronization status information, + * because the process has been disabled by the user via settings, + * or because there's nothing left to syncrhonize. + */ + public function cleanup_synchronization_state() { + delete_option( self::INITIAL_ORDERS_PENDING_SYNC_COUNT_OPTION ); + delete_option( self::PENDING_SYNC_IS_IN_PROGRESS_OPTION ); + delete_option( self::AUTO_FLIP_AUTHORITATIVE_TABLE_ROLES_OPTION ); + } }