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

wp-graphql / wp-graphql-woocommerce / 27386231983

12 Jun 2026 12:25AM UTC coverage: 91.791%. Remained the same
27386231983

Pull #1019

github

web-flow
Merge 46a421a18 into 01876f534
Pull Request #1019: fix: address WordPress.org plugin review (rename + prefixing + headers)

1327 of 1584 new or added lines in 200 files covered. (83.78%)

1 existing line in 1 file now uncovered.

18505 of 20160 relevant lines covered (91.79%)

151.6 hits per line

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

70.45
/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
/**
12
 * Class - Session_Transaction_Manager
13
 */
14
class Session_Transaction_Manager {
15
        /**
16
         * The request's transaction ID. Shared across all mutations in the same HTTP request.
17
         *
18
         * @var null|string
19
         */
20
        public $transaction_id = null;
21

22
        /**
23
         * Whether the transaction has been queued (added to the transaction queue).
24
         *
25
         * @var bool
26
         */
27
        private $is_queued = false;
28

29
        /**
30
         * Instance of parent session handler
31
         *
32
         * @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler
33
         */
34
        private $session_handler = null;
35

36
        /**
37
         * Singleton instance of class.
38
         *
39
         * @var \WPGraphQL\WooCommerce\Utils\Session_Transaction_Manager
40
         */
41
        private static $instance = null;
42

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

56
                return self::$instance;
146✔
57
        }
58

59
        /**
60
         * Session_Transaction_Manager constructor
61
         *
62
         * @param \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session_handler  Reference back to session handler.
63
         */
64
        public function __construct( &$session_handler ) {
65
                $this->session_handler = $session_handler;
39✔
66

67
                add_action( 'graphql_before_resolve_field', [ $this, 'update_transaction_queue' ], 10, 4 );
39✔
68
                add_action( 'graphql_mutation_response', [ $this, 'complete_mutation' ], 20, 6 );
39✔
69

70
                add_action( 'woographql_session_transaction_complete', [ $this->session_handler, 'save_if_dirty' ], 10 );
39✔
71

72
                add_action( 'woocommerce_add_to_cart', [ $this->session_handler, 'mark_dirty' ] );
39✔
73
                add_action( 'woocommerce_cart_item_removed', [ $this->session_handler, 'mark_dirty' ] );
39✔
74
                add_action( 'woocommerce_cart_item_restored', [ $this->session_handler, 'mark_dirty' ] );
39✔
75
                add_action( 'woocommerce_cart_item_set_quantity', [ $this->session_handler, 'mark_dirty' ] );
39✔
76
                add_action( 'woocommerce_cart_emptied', [ $this->session_handler, 'mark_dirty' ] );
39✔
77

78
                // Pop the transaction at the end of the request so all mutations in a batch
79
                // execute under the same queue entry without interleaving from other requests.
80
                register_shutdown_function( [ $this, 'pop_transaction_id' ] );
39✔
81
        }
82

83
        /**
84
         * Pass all member call upstream to the session handler.
85
         *
86
         * @param string $name  Name of class member.
87
         *
88
         * @return mixed
89
         */
90
        public function __get( $name ) {
91
                return $this->session_handler->{$name};
×
92
        }
93

94
        /**
95
         * Return array of all mutations that alter the session data.
96
         * a.k.a. Session Mutations
97
         *
98
         * @return array
99
         */
100
        public static function get_session_mutations() {
101
                /**
102
                 * All session altering mutations should be passed to the array.
103
                 */
104
                return \apply_filters(
32✔
105
                        'woographql_session_mutations',
32✔
106
                        [
32✔
107
                                'addToCart',
32✔
108
                                'updateItemQuantities',
32✔
109
                                'addFee',
32✔
110
                                'applyCoupon',
32✔
111
                                'removeCoupons',
32✔
112
                                'emptyCart',
32✔
113
                                'removeItemsFromCart',
32✔
114
                                'restoreCartItems',
32✔
115
                                'updateItemQuantities',
32✔
116
                                'updateShippingMethod',
32✔
117
                                'updateCustomer',
32✔
118
                                'updateSession',
32✔
119
                                'forgetSession',
32✔
120
                        ]
32✔
121
                );
32✔
122
        }
123

124
        /**
125
         * Returns the MySQL advisory lock name for the session's transaction queue.
126
         *
127
         * @return string
128
         */
129
        private function get_lock_name() {
130
                // MySQL advisory lock names are limited to 64 characters.
131
                $customer_id = $this->session_handler->get_customer_id();
28✔
132
                return 'woo_stq_' . substr( md5( (string) $customer_id ), 0, 20 );
28✔
133
        }
134

135
        /**
136
         * Acquires a MySQL advisory lock for atomic queue operations.
137
         *
138
         * @param int $timeout  Seconds to wait for lock acquisition.
139
         *
140
         * @return bool Whether the lock was acquired.
141
         */
142
        private function acquire_lock( $timeout = 10 ) {
143
                global $wpdb;
28✔
144
                $lock_name = $this->get_lock_name();
28✔
145
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery
146
                $result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, $timeout ) );
28✔
147
                return '1' === $result;
28✔
148
        }
149

150
        /**
151
         * Releases the MySQL advisory lock.
152
         *
153
         * @return void
154
         */
155
        private function release_lock() {
156
                global $wpdb;
28✔
157
                $lock_name = $this->get_lock_name();
28✔
158
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery
159
                $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
28✔
160
        }
161

162
        /**
163
         * Generates a timestamp-based transaction ID.
164
         *
165
         * Uses microtime to ensure chronological ordering when sorted alphabetically.
166
         *
167
         * @return string
168
         */
169
        private static function generate_transaction_id() {
170
                // Use zero-padded microtime for consistent alphabetical/chronological sorting.
171
                list( $usec, $sec ) = explode( ' ', microtime() );
25✔
172
                return sprintf( '%010d_%06d', $sec, intval( absint( $usec ) * 1000000 ) );
25✔
173
        }
174

175
        /**
176
         * Transaction queue workhorse.
177
         *
178
         * Creates a transaction ID if executing mutations that alter the session data, and stalls
179
         * execution until the transaction ID is at the top of the queue.
180
         *
181
         * @param mixed                                $source   Operation root object.
182
         * @param array                                $args     Operation arguments.
183
         * @param \WPGraphQL\AppContext                $context  AppContext instance.
184
         * @param \GraphQL\Type\Definition\ResolveInfo $info     Operation ResolveInfo object.
185
         *
186
         * @return void
187
         */
188
        public function update_transaction_queue( $source, $args, $context, $info ) {
189
                // Bail early, if not one of the session mutations.
190
                if ( ! in_array( $info->fieldName, self::get_session_mutations(), true ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
32✔
191
                        return;
32✔
192
                }
193

194
                // If transaction ID already exists and is queued, this is a subsequent mutation in the
195
                // same batch request. The queue entry is still at position [0], so just reload and proceed.
196
                if ( ! is_null( $this->transaction_id ) && $this->is_queued ) {
25✔
197
                        $this->session_handler->reload_data();
×
198
                        return;
×
199
                }
200

201
                // Initialize transaction ID once per request.
202
                if ( is_null( $this->transaction_id ) ) {
25✔
203
                        $this->transaction_id = self::generate_transaction_id();
25✔
204
                }
205

206
                // Wait until our transaction ID is at the top of the queue before continuing.
207
                if ( ! $this->next_transaction() ) {
25✔
208
                        usleep( 500000 );
×
209
                        $this->update_transaction_queue( $source, $args, $context, $info );
×
210
                } else {
211
                        $this->session_handler->reload_data();
25✔
212

213
                        // Set a timestamp on the transaction, which will allow us to check for any stale
214
                        // transactions that accidentally get left behind.
215
                        $this->set_timestamp();
25✔
216
                }
217
        }
218

219
        /**
220
         * Processes next transaction and returns whether the current transaction is the next transaction.
221
         *
222
         * @return bool
223
         */
224
        public function next_transaction() {
225
                // Update transaction queue.
226
                $transaction_queue = $this->get_transaction_queue();
28✔
227

228
                // If lead transaction object invalid pop transaction and loop.
229
                if ( ! is_array( $transaction_queue[0] ) ) {
28✔
230
                        $this->acquire_lock();
×
NEW
231
                        $transaction_queue = get_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
232
                        if ( ! empty( $transaction_queue ) ) {
×
233
                                array_shift( $transaction_queue );
×
234
                                $this->save_transaction_queue( $transaction_queue );
×
235
                        }
236
                        $this->release_lock();
×
237

238
                        // If current transaction is the lead exit loop.
239
                } elseif ( $this->transaction_id === $transaction_queue[0]['transaction_id'] ) {
28✔
240
                        return true;
28✔
241
                } elseif ( true === $this->did_transaction_expire( $transaction_queue ) ) {
×
242
                        // If transaction has expired, remove it from the queue array and continue loop.
243
                        $this->acquire_lock();
×
NEW
244
                        $transaction_queue = get_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
245
                        if ( ! empty( $transaction_queue ) ) {
×
246
                                array_shift( $transaction_queue );
×
247
                                $this->save_transaction_queue( $transaction_queue );
×
248
                        }
249
                        $this->release_lock();
×
250
                }
251

252
                return false;
×
253
        }
254

255
        /**
256
         * Adds transaction ID to the queue in sorted order and returns the transaction queue.
257
         *
258
         * Transaction IDs are timestamp-based, so alphabetical sorting preserves chronological order.
259
         * This ensures mutations from earlier requests always execute before mutations from later
260
         * requests, even if they are queued out of order.
261
         *
262
         * @return array
263
         */
264
        public function get_transaction_queue() {
265
                $this->acquire_lock();
28✔
266

267
                // Get transaction queue.
268
                $transaction_queue = get_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
28✔
269
                if ( ! $transaction_queue ) {
28✔
270
                        $transaction_queue = [];
28✔
271
                }
272

273
                // If transaction ID not in queue, add it in sorted order, and start transaction.
274
                if ( ! in_array( $this->transaction_id, array_column( $transaction_queue, 'transaction_id' ), true ) ) {
28✔
275
                        $transaction_id = $this->transaction_id;
28✔
276
                        $snapshot       = $this->session_handler->get_session_data();
28✔
277

278
                        $entry = compact( 'transaction_id', 'snapshot' );
28✔
279

280
                        // Insert in sorted position based on transaction ID (timestamp-based).
281
                        $inserted = false;
28✔
282
                        foreach ( $transaction_queue as $index => $queued ) {
28✔
283
                                if ( ! empty( $transaction_id ) && strcmp( $transaction_id, $queued['transaction_id'] ) < 0 ) {
×
284
                                        array_splice( $transaction_queue, $index, 0, [ $entry ] );
×
285
                                        $inserted = true;
×
286
                                        break;
×
287
                                }
288
                        }
289

290
                        if ( ! $inserted ) {
28✔
291
                                $transaction_queue[] = $entry;
28✔
292
                        }
293

294
                        // Update queue.
295
                        $this->save_transaction_queue( $transaction_queue );
28✔
296
                        $this->is_queued = true;
28✔
297
                }
298

299
                $this->release_lock();
28✔
300

301
                return $transaction_queue;
28✔
302
        }
303

304
        /**
305
         * Called after each mutation completes. Saves session data but does NOT pop
306
         * the transaction from the queue. The queue entry stays at position [0] to
307
         * block other requests until the entire HTTP request completes.
308
         *
309
         * @param array                                $payload          The Payload returned from the mutation.
310
         * @param array                                $input            The mutation input args, after being filtered by 'graphql_mutation_input'.
311
         * @param array                                $unfiltered_input The unfiltered input args of the mutation
312
         * @param \WPGraphQL\AppContext                $context          The AppContext object.
313
         * @param \GraphQL\Type\Definition\ResolveInfo $info             The ResolveInfo object.
314
         * @param string                               $mutation         The name of the mutation field.
315
         *
316
         * @return void
317
         */
318
        public function complete_mutation( $payload, $input, $unfiltered_input, $context, $info, $mutation ) {
319
                // Bail if transaction not started.
320
                if ( is_null( $this->transaction_id ) || ! $this->is_queued ) {
30✔
321
                        return;
17✔
322
                }
323

324
                // Bail if not a session mutation.
325
                if ( ! in_array( $mutation, self::get_session_mutations(), true ) ) {
25✔
326
                        return;
×
327
                }
328

329
                /**
330
                 * Mark mutation completion and save session data.
331
                 *
332
                 * @param string|null $transition_id     Current transaction ID.
333
                 * @param array       $transaction_queue Transaction Queue (not re-read here for performance).
334
                 */
335
                do_action( 'woographql_session_transaction_complete', $this->transaction_id, [] );
25✔
336
        }
337

338
        /**
339
         * Pop transaction ID off the top of the queue, ending the transaction.
340
         *
341
         * Called via register_shutdown_function at the end of the HTTP request, ensuring
342
         * all mutations in a batch complete before the queue position is released to
343
         * other requests.
344
         *
345
         * @return void
346
         */
347
        public function pop_transaction_id() {
348
                // Bail if transaction not started.
349
                if ( is_null( $this->transaction_id ) || ! $this->is_queued ) {
×
350
                        return;
×
351
                }
352

353
                $this->acquire_lock();
×
354

355
                // Get transaction queue.
NEW
356
                $transaction_queue = get_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
357

358
                if ( ! empty( $transaction_queue[0]['transaction_id'] ) && $this->transaction_id === $transaction_queue[0]['transaction_id'] ) {
×
359
                        // Remove Transaction ID and update queue.
360
                        array_shift( $transaction_queue );
×
361
                        $this->save_transaction_queue( $transaction_queue );
×
362
                }
363

364
                $this->release_lock();
×
365

366
                // Clear transaction state.
367
                $this->transaction_id = null;
×
368
                $this->is_queued      = false;
×
369
        }
370

371
        /**
372
         * Saves transaction queue.
373
         *
374
         * @param array $queue  Transaction queue.
375
         *
376
         * @return void
377
         */
378
        public function save_transaction_queue( $queue = [] ) {
379
                // If queue empty delete transient and bail.
380
                if ( empty( $queue ) ) {
28✔
NEW
381
                        delete_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
×
382
                        return;
×
383
                }
384

385
                // Save transaction queue.
386
                set_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}", $queue, 5 * MINUTE_IN_SECONDS );
28✔
387
        }
388

389
        /**
390
         * Create transaction timestamp.
391
         *
392
         * @return void
393
         */
394
        public function set_timestamp() {
395
                $this->acquire_lock();
25✔
396

397
                $transaction_queue = get_transient( "graphql_woocommerce_session_transactions_queue_{$this->session_handler->get_customer_id()}" );
25✔
398
                if ( ! $transaction_queue ) {
25✔
399
                        $transaction_queue = [];
×
400
                }
401

402
                // Bail if we don't have a queue to add a timestamp against.
403
                if ( empty( $transaction_queue[0] ) ) {
25✔
404
                        $this->release_lock();
×
405
                        return;
×
406
                }
407

408
                $transaction_queue[0]['timestamp'] = time();
25✔
409

410
                $this->save_transaction_queue( $transaction_queue );
25✔
411

412
                $this->release_lock();
25✔
413
        }
414

415
        /**
416
         * The length of time in seconds a transaction should stay in the queue
417
         *
418
         * @return mixed|void
419
         */
420
        public function get_timestamp_threshold() {
421
                return apply_filters( 'woographql_session_transaction_timeout', 30 );
2✔
422
        }
423

424
        /**
425
         * Whether the transaction has expired. This helps prevent infinite loops while searching through the transaction
426
         * queue.
427
         *
428
         * @param array $transaction_queue  Transaction queue.
429
         *
430
         * @return bool
431
         */
432
        public function did_transaction_expire( $transaction_queue ) {
433
                // Guard against empty transaction queue. We assume that it is invalid since we cannot calculate.
434
                if ( empty( $transaction_queue ) ) {
4✔
435
                        return true;
1✔
436
                }
437

438
                // Guard against empty timestamp. We assume that it is invalid since we cannot calculate.
439
                if ( empty( $transaction_queue[0] ) || empty( $transaction_queue[0]['timestamp'] ) ) {
3✔
440
                        return true;
1✔
441
                }
442

443
                $now        = time();
2✔
444
                $stamp      = $transaction_queue[0]['timestamp'];
2✔
445
                $threshold  = $this->get_timestamp_threshold();
2✔
446
                $difference = $now - $stamp;
2✔
447

448
                return $difference > $threshold;
2✔
449
        }
450
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc