Revert "Add support for '_held_for_checkout` records to prevent race conditions."

This commit is contained in:
Claudio Sanches 2019-12-04 16:03:05 -03:00 committed by GitHub
parent 0686e6f1cd
commit a12c25862b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 19 additions and 486 deletions

View File

@ -366,7 +366,6 @@ class WC_Checkout {
}
}
$order->hold_stock_for_checkout( WC()->cart );
$order->set_created_via( 'checkout' );
$order->set_cart_hash( $cart_hash );
$order->set_customer_id( apply_filters( 'woocommerce_checkout_customer_id', get_current_user_id() ) );
@ -404,9 +403,6 @@ class WC_Checkout {
return $order_id;
} catch ( Exception $e ) {
if ( $order && $order instanceof WC_Order ) {
$order->release_held_stock();
}
return new WP_Error( 'checkout-error', $e->getMessage() );
}
}

View File

@ -2059,135 +2059,4 @@ class WC_Order extends WC_Abstract_Order {
return apply_filters( 'woocommerce_get_order_item_totals', $total_rows, $this, $tax_display );
}
/**
* Adds a '_held_for_checkout` record for all products in cart.
*
* @since 3.9.0
* @param WC_Cart $cart Cart instance.
* @throws Exception When unable to hold stock for checkout.
*/
public function hold_stock_for_checkout( $cart ) {
/**
* Filter: woocommerce_hold_stock_for_checkout
* Allows enable/disable hold stock functionality on checkout.
*
* @since 3.9.0
* @param bool $enabled Default to true.
*/
if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) {
return;
}
if ( ! get_option( 'woocommerce_manage_stock' ) ) {
return;
}
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 );
if ( 0 >= $hold_stock_minutes ) {
return;
}
$product_qty_in_cart = $cart->get_cart_item_quantities();
$stock_held_keys = array();
$error = null;
try {
foreach ( $cart->get_cart() as $cart_item_key => $values ) {
$product = wc_get_product( $values['data'] );
if ( ! $product ) {
// Unsupported product!
continue;
}
$product_id = $product->get_stock_managed_by_id();
$result = $this->hold_product_for_checkout( $product, $product_qty_in_cart[ $product_id ] );
if ( false === $result ) {
// translators: Name of the product.
throw new Exception( sprintf( __( 'Something changed during checkout. %s is no longer available.', 'woocommerce' ), $product->get_name() ) );
}
if ( null === $result ) {
continue;
}
$stock_held_keys[ $product_id ] = $result;
}
} catch ( Exception $e ) {
$error = $e;
} finally {
if ( 0 < count( $stock_held_keys ) ) {
$this->record_held_stock( $stock_held_keys );
}
if ( $error instanceof Exception ) {
throw $error;
}
}
}
/**
* Adds a `_held_for_checkout` record for a product in checkout.
*
* @since 3.9.0
* @param WC_Product $product Instance of product.
* @param int $quantity Quantity of product to hold.
* @return bool|string|null Returns `false` when unable to hold stock, meta key when stock was held successfully, `null` when holding stock is not needed.
*/
protected function hold_product_for_checkout( $product, $quantity ) {
global $wpdb;
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
return null;
}
$product_id = $product->get_stock_managed_by_id();
$product_data_store = WC_Data_Store::load( 'product' );
$held_unique_str = wp_generate_password( 6, false );
$db_timestamp = $wpdb->get_var( 'SELECT UNIX_TIMESTAMP() FROM DUAL' );
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
$expire_timestamp = (int) $db_timestamp + ( $hold_stock_minutes * 60 );
$held_key = "_held_for_checkout_${expire_timestamp}_$held_unique_str";
$query_for_held_stock = $product_data_store->get_query_for_held_stock( $product_id );
$query_for_stock = $product_data_store->get_query_for_stock( $product_id );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$insert_statement = $wpdb->prepare(
"
INSERT INTO $wpdb->postmeta ( post_id, meta_key, meta_value )
SELECT %d, %s, %d from DUAL
WHERE ( $query_for_stock ) - ( $query_for_held_stock ) >= %d
",
$product->get_stock_managed_by_id(),
$held_key,
$quantity,
$quantity
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->query( $insert_statement ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $result > 0 ? $held_key : false;
}
/**
* Save keys used to held stock to DB.
*
* @since 3.9.0
* @param array $keys Array of keys to save.
*/
public function record_held_stock( $keys ) {
if ( is_array( $keys ) && 0 < count( $keys ) ) {
$this->update_meta_data( '_stock_held_keys', $keys );
}
}
/**
* Releases held stock, also deletes keys for the order.
*
* @since 3.9.0
*/
public function release_held_stock() {
$stock_held_keys = $this->get_meta( '_stock_held_keys' );
if ( is_array( $stock_held_keys ) ) {
foreach ( $stock_held_keys as $product_managed_id => $meta_key ) {
delete_post_meta( $product_managed_id, $meta_key );
}
}
$this->delete_meta_data( '_stock_held_keys' );
}
}

View File

@ -879,8 +879,8 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
$outofstock_where = ' AND exclude_join.object_id IS NULL';
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_results(
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
"
SELECT posts.ID as id, posts.post_parent as parent_id
FROM {$wpdb->posts} AS posts
@ -898,8 +898,8 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
)
GROUP BY posts.ID
"
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
/**
@ -1595,7 +1595,7 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
foreach ( $search_terms as $search_term ) {
$like = '%' . $wpdb->esc_like( $search_term ) . '%';
$term_group_query .= $wpdb->prepare( " {$searchand} ( ( posts.post_title LIKE %s) OR ( posts.post_excerpt LIKE %s) OR ( posts.post_content LIKE %s ) OR ( wc_product_meta_lookup.sku LIKE %s ) )", $like, $like, $like, $like ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$term_group_query .= $wpdb->prepare( " {$searchand} ( ( posts.post_title LIKE %s) OR ( posts.post_excerpt LIKE %s) OR ( posts.post_content LIKE %s ) OR ( wc_product_meta_lookup.sku LIKE %s ) )", $like, $like, $like, $like ); // @codingStandardsIgnoreLine.
$searchand = ' AND ';
}
@ -2052,48 +2052,4 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
}
return '';
}
/**
* Returns query statement for getting current `_stock` of a product.
*
* @since 3.9.0
* @param int $product_id Product ID.
* @return string|void Query statement.
*/
public function get_query_for_stock( $product_id ) {
global $wpdb;
return $wpdb->prepare(
// MAX function below is used to make sure result is a scalar.
"
SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta
WHERE {$wpdb->postmeta}.meta_key = '_stock'
AND {$wpdb->postmeta}.post_id = %d
FOR UPDATE
",
$product_id
);
}
/**
* Returns query statement for getting quantity of stock held by orders in checkout.
*
* @since 3.9.0
* @param int $product_id Product ID.
* @return string|void Query statement.
*/
public function get_query_for_held_stock( $product_id ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT COALESCE ( SUM( meta_value ), 0 ) FROM $wpdb->postmeta
WHERE {$wpdb->postmeta}.meta_key like %s
AND {$wpdb->postmeta}.meta_key > CONCAT( %s, UNIX_TIMESTAMP() )
AND {$wpdb->postmeta}.post_id = %d
FOR UPDATE
",
'_held_for_checkout_%',
'_held_for_checkout_',
$product_id
);
}
}

View File

@ -150,7 +150,7 @@ class WC_Shortcode_Checkout {
}
// Check stock based on all items in the cart and consider any held stock within pending orders.
$held_stock = ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0;
$held_stock = ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product, $order->get_id() ) : 0;
$required_stock = $quantities[ $product->get_stock_managed_by_id() ];
if ( $product->get_stock_quantity() < ( $held_stock + $required_stock ) ) {

View File

@ -803,8 +803,6 @@ function wc_order_search( $term ) {
function wc_update_total_sales_counts( $order_id ) {
$order = wc_get_order( $order_id );
$order->release_held_stock();
if ( ! $order || $order->get_data_store()->get_recorded_sales( $order ) ) {
return;
}
@ -882,23 +880,6 @@ function wc_update_coupon_usage_counts( $order_id ) {
}
}
}
/**
* Release held stock if any for an order.
*
* @since 3.9.0
* @param int $order_id Order ID.
*/
function wc_release_held_stock( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$order->release_held_stock();
}
add_action( 'woocommerce_order_status_cancelled', 'wc_release_held_stock' );
add_action( 'woocommerce_order_status_pending', 'wc_update_coupon_usage_counts' );
add_action( 'woocommerce_order_status_completed', 'wc_update_coupon_usage_counts' );
add_action( 'woocommerce_order_status_processing', 'wc_update_coupon_usage_counts' );

View File

@ -302,53 +302,28 @@ function wc_increase_stock_levels( $order_id ) {
function wc_get_held_stock_quantity( $product, $exclude_order_id = 0 ) {
global $wpdb;
/**
* Filter: woocommerce_hold_stock_for_checkout
* Allows enable/disable hold stock functionality on checkout.
*
* @since 3.9.0
* @param bool $enabled Default to true.
*/
if ( ! apply_filters( 'woocommerce_hold_stock_for_checkout', true ) ) {
/**
* DOES NOT SUPPORT `exclude_order_id` param, which was primarily being used to exclude the current order!
* While creating an order during checkout flow, this logic relies on the fact that order is not yet saved and therefore will be excluded.
*
* Query for calculating held stock which uses '_held_for_checkout' records instead of actually querying all orders with wc-pending status.
* This is introduced to handle race conditions.
*
* @since 3.9.0
*/
$product_data_store = WC_Data_Store::load( 'product' );
$product_id = $product->get_stock_managed_by_id();
return $wpdb->get_var( $product_data_store->get_query_for_held_stock( $product_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}
return $wpdb->get_var(
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->prepare(
"
SELECT SUM( order_item_meta.meta_value ) AS held_qty
FROM {$wpdb->posts} AS posts
LEFT JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_items as order_items ON posts.ID = order_items.order_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta ON order_items.order_item_id = order_item_meta.order_item_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta2 ON order_items.order_item_id = order_item_meta2.order_item_id
WHERE order_item_meta.meta_key = '_qty'
AND order_item_meta2.meta_key = %s
AND order_item_meta2.meta_value = %d
AND postmeta.meta_key = '_created_via'
AND postmeta.meta_value = 'checkout'
AND posts.post_type IN ( '" . implode( "','", wc_get_order_types() ) . "' )
AND posts.post_status = 'wc-pending'
AND posts.ID != %d;
",
SELECT SUM( order_item_meta.meta_value ) AS held_qty
FROM {$wpdb->posts} AS posts
LEFT JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_items as order_items ON posts.ID = order_items.order_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta ON order_items.order_item_id = order_item_meta.order_item_id
LEFT JOIN {$wpdb->prefix}woocommerce_order_itemmeta as order_item_meta2 ON order_items.order_item_id = order_item_meta2.order_item_id
WHERE order_item_meta.meta_key = '_qty'
AND order_item_meta2.meta_key = %s
AND order_item_meta2.meta_value = %d
AND postmeta.meta_key = '_created_via'
AND postmeta.meta_value = 'checkout'
AND posts.post_type IN ( '" . implode( "','", wc_get_order_types() ) . "' )
AND posts.post_status = 'wc-pending'
AND posts.ID != %d;",
'product_variation' === get_post_type( $product->get_stock_managed_by_id() ) ? '_variation_id' : '_product_id',
$product->get_stock_managed_by_id(),
$exclude_order_id
)
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
);
); // WPCS: unprepared SQL ok.
}
/**

View File

@ -1,151 +0,0 @@
<?php
/**
* Checkout tests.
*
* @package WooCommerce|Tests|Checkout
*/
/**
* Class WC_Checkout
*/
class WC_Tests_Checkout extends WC_Unit_Test_Case {
/**
* TearDown.
*/
public function tearDown() {
parent::tearDown();
WC()->cart->empty_cart();
}
/**
* Setup.
*/
public function setUp() {
parent::setUp();
WC()->cart->empty_cart();
}
/**
* Helper method to create a managed product and a order for that product.
*
* @return array( WC_Product, WC_Order ) array
* @throws Exception When unable to create an order.
*/
protected function create_order_for_managed_inventory_product() {
$product = WC_Helper_Product::create_simple_product();
$product->set_props( array( 'manage_stock' => true ) );
$product->set_stock_quantity( 10 );
$product->save();
WC()->cart->add_to_cart( $product->get_id(), 9 );
$this->assertEquals( true, WC()->cart->check_cart_items() );
$checkout = WC_Checkout::instance();
$order_id = $checkout->create_order(
array(
'payment_method' => 'cod',
'billing_email' => 'a@b.com',
)
);
// Assertions whether the order was created successfully.
$this->assertNotWPError( $order_id );
$order = wc_get_order( $order_id );
return array( $product, $order );
}
/**
* Test when order is out stock because it is held by an order in pending status.
* @throws Exception When unable to create order.
*/
public function test_create_order_when_out_of_stock() {
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
$this->assertEquals( 9, $order->get_item_count() );
$this->assertEquals( 'pending', $order->get_status() );
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
WC()->cart->empty_cart();
WC()->cart->add_to_cart( $product->get_stock_managed_by_id(), 2 );
$this->assertEquals( false, WC()->cart->check_cart_items() );
}
/**
* Legacy version for test `test_create_order_when_out_of_stock` above.
* @throws Exception When unable to create order.
*/
public function test_create_order_when_out_of_stock_legacy() {
add_filter( 'woocommerce_hold_stock_for_checkout', '__return_false' );
$this->test_create_order_when_out_of_stock();
}
/**
* Test if pending stock is cleared when order is cancelled.
*
* @throws Exception When unable to create order.
*/
public function test_pending_is_cleared_when_order_is_cancelled() {
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
$order->set_status( 'cancelled' );
$order->save();
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
$this->assertEquals( 10, $product->get_stock_quantity() );
}
/**
* Test if pending stock is cleared when order is processing.
*
* @throws Exception When unable to create order.
*/
public function test_pending_is_cleared_when_order_processed() {
list( $product, $order ) = $this->create_order_for_managed_inventory_product();
$this->assertEquals( 9, wc_get_held_stock_quantity( $product ) );
$order->set_status( 'processing' );
$order->save();
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
}
/**
* Test creating order from managed stock for variable product.
* @throws Exception When unable to create an order.
*/
public function test_create_order_for_variation_product() {
$parent_product = WC_Helper_Product::create_variation_product();
$variation = $parent_product->get_available_variations()[0];
$variation = wc_get_product( $variation['variation_id'] );
$variation->set_manage_stock( true );
$variation->set_stock_quantity( 10 );
$variation->save();
WC()->cart->add_to_cart( $variation->get_id(), 9 );
$this->assertEquals( true, WC()->cart->check_cart_items() );
$checkout = WC_Checkout::instance();
$order_id = $checkout->create_order(
array(
'payment_method' => 'cod',
'billing_email' => 'a@b.com',
)
);
// Assertions whether the first order was created successfully.
$this->assertNotWPError( $order_id );
$order = wc_get_order( $order_id );
$this->assertEquals( 9, $order->get_item_count() );
$this->assertEquals( 'pending', $order->get_status() );
$this->assertEquals( 9, wc_get_held_stock_quantity( $variation ) );
WC()->cart->empty_cart();
WC()->cart->add_to_cart( $variation->get_stock_managed_by_id(), 2 );
$this->assertEquals( false, WC()->cart->check_cart_items() );
}
}

View File

@ -1,93 +0,0 @@
<?php
/**
* Class WC_Tests_Order file.
*
* @package WooCommerce/Tests
*/
/**
* Class Functions.
*
* @package WooCommerce/Tests/Order
* @since 3.9.0
*/
class WC_Tests_Order extends WC_Unit_Test_Case {
/**
* TearDown.
*/
public function tearDown() {
parent::tearDown();
WC()->cart->empty_cart();
}
/**
* Test pending stock and `release_held_stock` and `record_held_stock` methods as well.
* @throws Exception When unable to create an order.
*/
public function test_pending_stock_for_order_with_multiple_product() {
$product1 = WC_Helper_Product::create_simple_product();
$product1->set_props( array( 'manage_stock' => true ) );
$product1->set_stock_quantity( 10 );
$product1->save();
$product2 = WC_Helper_Product::create_simple_product();
$product2->set_props( array( 'manage_stock' => true ) );
$product2->set_stock_quantity( 10 );
$product2->save();
WC()->cart->add_to_cart( $product1->get_id(), 9 );
WC()->cart->add_to_cart( $product2->get_id(), 5 );
$this->assertEquals( true, WC()->cart->check_cart_items() );
$checkout = WC_Checkout::instance();
$order_id = $checkout->create_order(
array(
'payment_method' => 'cod',
'billing_email' => 'a@b.com',
)
);
$this->assertNotWPError( $order_id );
$order_held_stock_keys = get_post_meta( $order_id, '_stock_held_keys', true );
$product1_id = $product1->get_stock_managed_by_id();
$product2_id = $product2->get_stock_managed_by_id();
$this->assertEquals( true, in_array( $product1_id, array_keys( $order_held_stock_keys ) ) );
$this->assertEquals( true, in_array( $product2_id, array_keys( $order_held_stock_keys ) ) );
$this->assertEquals( 9, wc_get_held_stock_quantity( $product1 ) );
$this->assertEquals( 5, wc_get_held_stock_quantity( $product2 ) );
$order = wc_get_order( $order_id );
$order->release_held_stock();
$this->assertEquals( 0, wc_get_held_stock_quantity( $product1 ) );
$this->assertEquals( 0, wc_get_held_stock_quantity( $product2 ) );
}
/**
* Test `release_held_stock` function of unmanaged product.
* @throws Exception When unable to create an order.
*/
public function test_release_held_of_unmanaged_product() {
$product = WC_Helper_Product::create_simple_product();
WC()->cart->add_to_cart( $product->get_id(), 9 );
$this->assertEquals( true, WC()->cart->check_cart_items() );
$checkout = WC_Checkout::instance();
$order_id = $checkout->create_order(
array(
'payment_method' => 'cod',
'billing_email' => 'a@b.com',
)
);
$this->assertNotWPError( $order_id );
$order = wc_get_order( $order_id );
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
$order->release_held_stock();
$this->assertEquals( 0, wc_get_held_stock_quantity( $product ) );
}
}