• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql-woocommerce / 7340331855

27 Dec 2023 05:01PM UTC coverage: 84.726% (-0.05%) from 84.771%
7340331855

push

github

web-flow
fix: Session transaction queue fix (#832)

* fix: Session transaction queue pop relocated to earlier occurrence in request

* chore: Linter and PHPStan compliance met

2 of 5 new or added lines in 1 file covered. (40.0%)

4 existing lines in 2 files now uncovered.

11083 of 13081 relevant lines covered (84.73%)

58.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

29.89
/includes/utils/class-session-transaction-manager.php
1
<?php
2
/**
3
 * Manages concurrent requests that executes mutations on the session data.
4
 *
5
 * @package WPGraphQL\WooCommerce\Utils
6
 * @since 0.7.1
7
 */
8

9
namespace WPGraphQL\WooCommerce\Utils;
10

11
use GraphQL\Error\UserError;
12

13
/**
14
 * Class - Session_Transaction_Manager
15
 */
16
class Session_Transaction_Manager {
17
        /**
18
         * The request's transaction ID.
19
         *
20
         * @var null|string
21
         */
22
        public $transaction_id = null;
23

24
        /**
25
         * Instance of parent session handler
26
         *
27
         * @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler
28
         */
29
        private $session_handler = null;
30

31
        /**
32
         * Singleton instance of class.
33
         *
34
         * @var \WPGraphQL\WooCommerce\Utils\Session_Transaction_Manager
35
         */
36
        private static $instance = null;
37

38
        /**
39
         * Singleton retriever and cleaner.
40
         * Should not be called anywhere but in the session handler init function.
41
         *
42
         * @param \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session_handler  WooCommerce Session Handler instance.
43
         *
44
         * @return \WPGraphQL\WooCommerce\Utils\Session_Transaction_Manager
45
         */
46
        public static function get( &$session_handler ) {
47
                if ( is_null( self::$instance ) ) {
27✔
48
                        self::$instance = new self( $session_handler );
1✔
49
                }
50

51
                return self::$instance;
27✔
52
        }
53

54
        /**
55
         * Session_Transaction_Manager constructor
56
         *
57
         * @param \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session_handler  Reference back to session handler.
58
         */
59
        public function __construct( &$session_handler ) {
60
                $this->session_handler = $session_handler;
1✔
61

62
                add_action( 'graphql_before_resolve_field', [ $this, 'update_transaction_queue' ], 10, 4 );
1✔
63
                add_action( 'graphql_mutation_response', [ $this, 'pop_transaction_id' ], 20, 6 );
1✔
64
                add_action( 'woographql_session_transaction_complete', [ $this->session_handler, 'save_if_dirty' ], 10 );
1✔
65
        }
66

67
        /**
68
         * Pass all member call upstream to the session handler.
69
         *
70
         * @param string $name  Name of class member.
71
         *
72
         * @return mixed
73
         */
74
        public function __get( $name ) {
75
                return $this->session_handler->{$name};
×
76
        }
77

78
        /**
79
         * Return array of all mutations that alter the session data.
80
         * a.k.a. Session Mutations
81
         *
82
         * @return array
83
         */
84
        public static function get_session_mutations() {
85
                /**
86
                 * All session altering mutations should be passed to the array.
87
                 */
88
                return \apply_filters(
1✔
89
                        'woographql_session_mutations',
1✔
90
                        [
1✔
91
                                'addToCart',
1✔
92
                                'updateItemQuantities',
1✔
93
                                'addFee',
1✔
94
                                'applyCoupon',
1✔
95
                                'removeCoupons',
1✔
96
                                'emptyCart',
1✔
97
                                'removeItemsFromCart',
1✔
98
                                'restoreCartItems',
1✔
99
                                'updateItemQuantities',
1✔
100
                                'updateShippingMethod',
1✔
101
                                'updateCustomer',
1✔
102
                                'updateSession',
1✔
103
                        ]
1✔
104
                );
1✔
105
        }
106

107
        /**
108
         * Transaction queue workhorse.
109
         *
110
         * Creates an transaction ID if executing mutations that alter the session data, and stales
111
         * execution until the transaction ID is at the top of the queue.
112
         *
113
         * @param mixed                                $source   Operation root object.
114
         * @param array                                $args     Operation arguments.
115
         * @param \WPGraphQL\AppContext                $context  AppContext instance.
116
         * @param \GraphQL\Type\Definition\ResolveInfo $info     Operation ResolveInfo object.
117
         *
118
         * @return void
119
         */
120
        public function update_transaction_queue( $source, $args, $context, $info ) {
121
                // Bail early, if not one of the session mutations.
122
                if ( ! in_array( $info->fieldName, self::get_session_mutations(), true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1✔
123
                        return;
1✔
124
                }
125

126
                // Bail if transaction has already been completed. There are times when the underlying action runs twice.
127
                if ( ! is_null( $this->transaction_id ) ) {
×
128
                        $transaction_queue = get_transient( "woo_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
129
                        if ( in_array( $this->transaction_id, array_column( $transaction_queue, 'transaction_id' ), true ) ) {
×
130
                                return;
×
131
                        }
132
                } else {
133
                        // Initialize transaction ID.
134
                        $mutation             = $info->fieldName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
×
135
                        $this->transaction_id = \uniqid( "wooSession_{$mutation}_" );
×
136
                }
137

138
                // Wait until our transaction ID is at the top of the queue before continuing.
139
                if ( ! $this->next_transaction() ) {
×
140
                        usleep( 500000 );
×
141
                        $this->update_transaction_queue( $source, $args, $context, $info );
×
142
                } else {
143
                        $this->session_handler->reload_data();
×
144

145
                        // Set a timestamp on the transaction, which will allow us to check for any stale transactions that accidentally get left behind.
146
                        $this->set_timestamp();
×
147
                }
148
        }
149

150
        /**
151
         * Processes next transaction and returns whether the current transaction is the next transaction.
152
         *
153
         * @return bool
154
         */
155
        public function next_transaction() {
156
                // Update transaction queue.
157
                $transaction_queue = $this->get_transaction_queue();
×
158

159
                // If lead transaction object invalid pop transaction and loop.
160
                if ( ! is_array( $transaction_queue[0] ) ) {
×
161
                        array_shift( $transaction_queue );
×
162
                        $this->save_transaction_queue( $transaction_queue );
×
163

164
                        // If current transaction is the lead exit loop.
165
                } elseif ( $this->transaction_id === $transaction_queue[0]['transaction_id'] ) {
×
166
                        return true;
×
167
                } elseif ( true === $this->did_transaction_expire( $transaction_queue ) ) {
×
168
                        // If transaction has expired, remove it from the queue array and continue loop.
169
                        array_shift( $transaction_queue );
×
170
                        $this->save_transaction_queue( $transaction_queue );
×
171
                }
172

173
                return false;
×
174
        }
175

176
        /**
177
         * Adds transaction ID to the end of the queue, officially starting the transaction,
178
         * and returns the transaction queue.
179
         *
180
         * @return array
181
         */
182
        public function get_transaction_queue() {
183
                // Get transaction queue.
184
                $transaction_queue = get_transient( "woo_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
185
                if ( ! $transaction_queue ) {
×
186
                        $transaction_queue = [];
×
187
                }
188

189
                // If transaction ID not in queue, add it, and start transaction.
190
                if ( ! in_array( $this->transaction_id, array_column( $transaction_queue, 'transaction_id' ), true ) ) {
×
191
                        $transaction_id = $this->transaction_id;
×
192
                        $snapshot       = $this->session_handler->get_session_data();
×
193

194
                        $transaction_queue[] = compact( 'transaction_id', 'snapshot' );
×
195

196
                        // Update queue.
197
                        $this->save_transaction_queue( $transaction_queue );
×
198
                }
199

200
                return $transaction_queue;
×
201
        }
202

203
        /**
204
         * Pop transaction ID off the top of the queue, ending the transaction.
205
         *
206
         * @param array                                $payload          The Payload returned from the mutation.
207
         * @param array                                $input            The mutation input args, after being filtered by 'graphql_mutation_input'.
208
         * @param array                                $unfiltered_input The unfiltered input args of the mutation
209
         * @param \WPGraphQL\AppContext                $context          The AppContext object.
210
         * @param \GraphQL\Type\Definition\ResolveInfo $info             The ResolveInfo object.
211
         * @param string                               $mutation         The name of the mutation field.
212
         *
213
         * @throws \GraphQL\Error\UserError If transaction ID is not on the top of the queue.
214
         *
215
         * @return void
216
         */
217
        public function pop_transaction_id( $payload, $input, $unfiltered_input, $context, $info, $mutation ) {
218
                // Bail if transaction not started.
219
                if ( is_null( $this->transaction_id ) ) {
×
220
                        return;
×
221
                }
222

223
                // Bail if not the expected mutation.
NEW
224
                if ( str_starts_with( $this->transaction_id, "wooSession_{$mutation}_" ) ) {
×
NEW
225
                        return;
×
226
                }
227

228
                // Get transaction queue.
229
                $transaction_queue = get_transient( "woo_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
230

231
                // Throw if transaction ID not on top.
232
                if ( $this->transaction_id !== $transaction_queue[0]['transaction_id'] ) {
×
233
                        throw new UserError( __( 'Woo session transaction executed out of order', 'wp-graphql-woocommerce' ) );
×
234
                } else {
235

236
                        // Remove Transaction ID and update queue.
237
                        array_shift( $transaction_queue );
×
238
                        $this->save_transaction_queue( $transaction_queue );
×
239

240
                        /**
241
                         * Mark transaction completion
242
                         * 
243
                         * @param string|null $transition_id     Removed transaction ID.
244
                         * @param array       $transaction_queue Transaction Queue.
245
                         */
NEW
246
                        do_action( 'woographql_session_transaction_complete', $this->transaction_id, $transaction_queue );
×
247
                        
248
                        // Clear transaction ID.
UNCOV
249
                        $this->transaction_id = null;
×
250
                }
251
        }
252

253
        /**
254
         * Saves transaction queue.
255
         *
256
         * @param array $queue  Transaction queue.
257
         *
258
         * @return void
259
         */
260
        public function save_transaction_queue( $queue = [] ) {
261
                // If queue empty delete transient and bail.
262
                if ( empty( $queue ) ) {
×
263
                        delete_transient( "woo_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
264
                        return;
×
265
                }
266

267
                // Save transaction queue.
268
                set_transient( "woo_session_transactions_queue_{$this->session_handler->get_customer_id()}", $queue, 5 * MINUTE_IN_SECONDS );
×
269
        }
270

271
        /**
272
         * Create transaction timestamp.
273
         *
274
         * @return void
275
         */
276
        public function set_timestamp() {
277
                $transaction_queue = $this->get_transaction_queue();
×
278

279
                // Bail if we don't have a queue to add a timestamp against.
280
                if ( empty( $transaction_queue[0] ) ) {
×
281
                        return;
×
282
                }
283

284
                $transaction_queue[0]['timestamp'] = time();
×
285

286
                $this->save_transaction_queue( $transaction_queue );
×
287
        }
288

289
        /**
290
         * The length of time in seconds a transaction should stay in the queue
291
         *
292
         * @return mixed|void
293
         */
294
        public function get_timestamp_threshold() {
295
                return apply_filters( 'woographql_session_transaction_timeout', 30 );
×
296
        }
297

298
        /**
299
         * Whether the transaction has expired. This helps prevent infinite loops while searching through the transaction
300
         * queue.
301
         *
302
         * @param array $transaction_queue  Transaction queue.
303
         *
304
         * @return bool
305
         */
306
        public function did_transaction_expire( $transaction_queue ) {
307
                // Guard against empty transaction queue. We assume that it is invalid since we cannot calculate.
308
                if ( empty( $transaction_queue ) ) {
×
309
                        return true;
×
310
                }
311

312
                // Guard against empty timestamp. We assume that it is invalid since we cannot calculate.
313
                if ( empty( $transaction_queue[0] ) || empty( $transaction_queue[0]['timestamp'] ) ) {
×
314
                        return true;
×
315
                }
316

317
                $now        = time();
×
318
                $stamp      = $transaction_queue[0]['timestamp'];
×
319
                $threshold  = $this->get_timestamp_threshold();
×
320
                $difference = $now - $stamp;
×
321

322
                return $difference > $threshold;
×
323
        }
324
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc