Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Update reserved stock class to handle concurrent requests #1778

Merged
merged 7 commits into from
Mar 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/Library.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public static function maybe_create_tables() {
`product_id` bigint(20) NOT NULL,
`stock_quantity` double NOT NULL DEFAULT 0,
`timestamp` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`, `product_id`)
) $collate;
"
Expand Down
26 changes: 8 additions & 18 deletions src/RestApi/StoreApi/Controllers/CartOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use \WP_REST_Request as RestRequest;
use \WC_REST_Exception as RestException;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Schemas\OrderSchema;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock;

/**
* Cart Order API.
Expand Down Expand Up @@ -127,9 +126,15 @@ public function create_item( $request ) {
// Create or retrieve the draft order for the current cart.
$order_object = $this->create_order_from_cart( $request );

// Try to reserve stock, if available.
$this->reserve_stock( $order_object );
// Try to reserve stock for 10 mins, if available.
// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}

$reserve_stock->reserve_stock_for_order( $order_object, 10 );
$response = $this->prepare_item_for_response( $order_object, $request );
$response->set_status( 201 );
return $response;
Expand Down Expand Up @@ -167,21 +172,6 @@ protected function update_session( RestRequest $request ) {
WC()->customer->save();
}

/**
* Put a temporary hold on stock for this order.
*
* @throws RestException Exception when stock cannot be reserved.
* @param \WC_Order $order Order object.
*/
protected function reserve_stock( \WC_Order $order ) {
$reserve_stock_helper = new ReserveStock();
$result = $reserve_stock_helper->reserve_stock_for_order( $order );

if ( is_wp_error( $result ) ) {
throw new RestException( $result->get_error_code(), $result->get_error_message(), $result->get_error_data( 'status' ) );
}
}

/**
* Create order and set props based on global settings.
*
Expand Down
12 changes: 9 additions & 3 deletions src/RestApi/StoreApi/Schemas/CartItemSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@

use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductImages;
use Automattic\WooCommerce\Blocks\RestApi\Utilities\ProductSummary;
use Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock;

/**
* CartItemSchema class.
Expand Down Expand Up @@ -335,8 +334,15 @@ protected function get_low_stock_remaining( \WC_Product $product ) {
return null;
}

$draft_order = WC()->session->get( 'store_api_draft_order' );
$reserve_stock = new ReserveStock();
$draft_order = WC()->session->get( 'store_api_draft_order' );

// @todo Remove once min support for WC reaches 4.0.0.
if ( \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) ) {
$reserve_stock = new \Automattic\WooCommerce\Checkout\Helpers\ReserveStock();
} else {
$reserve_stock = new \Automattic\WooCommerce\Blocks\RestApi\StoreApi\Utilities\ReserveStock();
}

$reserved_stock = $reserve_stock->get_reserved_stock( $product, isset( $draft_order['id'] ) ? $draft_order['id'] : 0 );
$remaining_stock = $product->get_stock_quantity() - $reserved_stock;

Expand Down
247 changes: 155 additions & 92 deletions src/RestApi/StoreApi/Utilities/ReserveStock.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php
/**
* Helper class to handle product stock reservation.
* Handle product stock reservation during checkout.
*
* @package WooCommerce/Blocks
*/
Expand All @@ -9,132 +9,195 @@

defined( 'ABSPATH' ) || exit;

use \WP_Error;

/**
* Stock Reservation class.
*/
class ReserveStock {
final class ReserveStock {
/**
* Query for any existing holds on stock for this item.
*
* @param \WC_Product $product Product to get reserved stock for.
* @param integer $exclude_order_id Optional order to exclude from the results.
*
* @return integer Amount of stock already reserved.
*/
public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) {
global $wpdb;

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return (int) $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) );
}

/**
* Put a temporary hold on stock for an order if enough is available.
*
* @throws ReserveStockException If stock cannot be reserved.
*
* @param \WC_Order $order Order object.
* @return bool|WP_Error
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
*/
public function reserve_stock_for_order( \WC_Order $order ) {
$stock_to_reserve = [];
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product;
}
);
public function reserve_stock_for_order( \WC_Order $order, $minutes = 0 ) {
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );

foreach ( $items as $item ) {
$product = $item->get_product();

if ( ! $product->is_in_stock() ) {
return new WP_Error(
'product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
[ 'status' => 403 ]
);
}
if ( ! $minutes ) {
return;
}

// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
try {
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
}
);
$rows = [];

foreach ( $items as $item ) {
$product = $item->get_product();

if ( ! $product->is_in_stock() ) {
throw new ReserveStockException(
'product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '%s is out of stock and cannot be purchased.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
403
);
}

// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}

$managed_by_id = $product->get_stock_managed_by_id();
$rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item->get_quantity() : $item->get_quantity();
}

$product_id = $product->get_stock_managed_by_id();
$stock_to_reserve[ $product_id ] = isset( $stock_to_reserve[ $product_id ] ) ? $stock_to_reserve[ $product_id ] : 0;
$reserved_stock = $this->get_reserved_stock( $product, $order->get_id() );

if ( ( $product->get_stock_quantity() - $reserved_stock - $stock_to_reserve[ $product_id ] ) < $item->get_quantity() ) {
return new WP_Error(
'product_not_enough_stock',
sprintf(
/* translators: %s: product name */
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woo-gutenberg-products-block' ),
$product->get_name()
),
[ 'status' => 403 ]
);
if ( ! empty( $rows ) ) {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
}

// Queue for later DB insertion.
$stock_to_reserve[ $product_id ] += $item->get_quantity();
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
throw $e;
}

$this->reserve_stock( $stock_to_reserve, $order->get_id() );

return true;
}

/**
* Reserve stock by inserting rows into the DB.
* Release a temporary hold on stock for an order.
*
* @param array $stock_to_reserve Array of Product ID => Qty pairs.
* @param integer $order_id Order ID for which to reserve stock.
* @param \WC_Order $order Order object.
*/
protected function reserve_stock( $stock_to_reserve, $order_id ) {
public function release_stock_for_order( \WC_Order $order ) {
global $wpdb;

$stock_to_reserve = array_filter( $stock_to_reserve );

if ( ! $stock_to_reserve ) {
return;
}

$stock_to_reserve_rows = [];

foreach ( $stock_to_reserve as $product_id => $stock_quantity ) {
$stock_to_reserve_rows[] = '(' . esc_sql( $order_id ) . ',"' . esc_sql( $product_id ) . '","' . esc_sql( $stock_quantity ) . '")';
}

$values = implode( ',', $stock_to_reserve_rows );

// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( "REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity ) VALUES {$values};" );
$wpdb->delete(
$wpdb->wc_reserved_stock,
[
'order_id' => $order->get_id(),
]
);
}

/**
* Query for any existing holds on stock for this item.
* Reserve stock for a product by inserting rows into the DB.
*
* - Can ignore reserved stock for a specific order.
* - Ignores stock for orders which are no longer drafts (assuming real stock reduction was performed).
* - Ignores stock reserved over 10 mins ago.
* @throws ReserveStockException If a row cannot be inserted.
*
* @param \WC_Product $product Product to get reserved stock for.
* @param integer $exclude_order_id Optional order to exclude from the results.
* @return integer Amount of stock already reserved.
* @param int $product_id Product ID which is having stock reserved.
* @param int $stock_quantity Stock amount to reserve.
* @param \WC_Order $order Order object which contains the product.
* @param int $minutes How long to reserve stock in minutes.
*/
public function get_reserved_stock( \WC_Product $product, $exclude_order_id = 0 ) {
private function reserve_stock_for_product( $product_id, $stock_quantity, \WC_Order $order, $minutes ) {
global $wpdb;

$reserved_stock = $wpdb->get_var(
$query_for_stock = $this->get_query_for_stock( $product_id );
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
$required_stock = $stock_quantity;

// Deals with legacy stock reservations from woo core.
$support_legacy_held_stock = ! \class_exists( '\Automattic\WooCommerce\Checkout\Helpers\ReserveStock' ) && absint( get_option( 'woocommerce_hold_stock_minutes', 0 ) ) > 0;

if ( $support_legacy_held_stock ) {
$required_stock += wc_get_held_stock_quantity( wc_get_product( $product_id ) );
}

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query(
$wpdb->prepare(
"
SELECT SUM( stock_table.`stock_quantity` ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
WHERE stock_table.`product_id` = %d
AND posts.post_status = 'wc-checkout-draft'
AND stock_table.`order_id` != %d
AND stock_table.`timestamp` > ( NOW() - INTERVAL 10 MINUTE )
REPLACE INTO {$wpdb->wc_reserved_stock} ( order_id, product_id, stock_quantity, expires )
SELECT %d, %d, %d, ( NOW() + INTERVAL %d MINUTE ) from DUAL
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
",
$product->get_stock_managed_by_id(),
$exclude_order_id
$order->get_id(),
$product_id,
$stock_quantity,
$minutes,
$required_stock
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared

if ( ! $result ) {
$product = wc_get_product( $product_id );
throw new ReserveStockException(
'product_not_enough_stock',
sprintf(
/* translators: %s: product name */
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woo-gutenberg-products-block' ),
$product ? $product->get_name() : '#' . $product_id
),
403
);
}
}

// Deals with legacy stock reservation which the core Woo checkout performs.
$hold_stock_minutes = (int) get_option( 'woocommerce_hold_stock_minutes', 0 );
$reserved_stock += ( $hold_stock_minutes > 0 ) ? wc_get_held_stock_quantity( $product ) : 0;
/**
* Returns query statement for getting current `_stock` of a product.
*
* @todo Once merged to woo core data store, this method can be removed.
* @internal MAX function below is used to make sure result is a scalar.
* @param int $product_id Product ID.
* @return string|void Query statement.
*/
private function get_query_for_stock( $product_id ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT COALESCE ( MAX( meta_value ), 0 ) FROM $wpdb->postmeta as meta_table
WHERE meta_table.meta_key = '_stock'
AND meta_table.post_id = %d
",
$product_id
);
}

return $reserved_stock;
/**
* Returns query statement for getting reserved stock of a product.
*
* @param int $product_id Product ID.
* @param integer $exclude_order_id Optional order to exclude from the results.
* @return string|void Query statement.
*/
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
WHERE posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )
AND stock_table.`expires` > NOW()
AND stock_table.`product_id` = %d
AND stock_table.`order_id` != %d
",
$product_id,
$exclude_order_id
);
}
}
Loading