PluginProbe ʕ •ᴥ•ʔ
WooCommerce Square / 5.5.0
WooCommerce Square v5.5.0
5.5.0 5.4.3 5.4.2 5.4.1 5.4.0 trunk 1.0.25 1.0.26 1.0.27 1.0.28 1.0.29 1.0.30 1.0.31 1.0.32 1.0.33 1.0.34 1.0.35 1.0.36 1.0.37 1.0.38 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3.0 2.3.1 2.3.2 2.3.3 2.3.4 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.6.0 2.7.0 2.8.0 2.9.0 2.9.1 3.0.0 3.0.1 3.0.2 3.0.3 3.1.0 3.2.0 3.3.0 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.7.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 4.0.0 4.1.0 4.2.0 4.2.1 4.2.2 4.2.3 4.3.0 4.3.1 4.3.2 4.4.0 4.4.1 4.4.2 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.6.2 4.6.3 4.6.4 4.7.0 4.7.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.1 4.8.2 4.8.3 4.8.4 4.8.5 4.8.6 4.8.7 4.8.8 4.9.0 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 4.9.6 4.9.7 4.9.8 4.9.9 5.0.0 5.0.1 5.1.0 5.1.1 5.1.2 5.2.0 5.3.0 5.3.1 5.3.2 5.3.3
woocommerce-square / includes / Sync / Stepped_Job.php
woocommerce-square / includes / Sync Last commit date
Records 3 months ago Catalog_Item.php 9 months ago Helper.php 3 days ago Interval_Polling.php 3 days ago Job.php 2 years ago Manual_Synchronization.php 3 days ago Order_Importer.php 11 months ago Order_Mapper.php 11 months ago Order_Polling.php 11 months ago Product_Import.php 3 days ago Records.php 2 years ago Stepped_Job.php 3 days ago
Stepped_Job.php
474 lines
1 <?php
2 /**
3 * WooCommerce Square
4 *
5 * This source file is subject to the GNU General Public License v3.0
6 * that is bundled with this package in the file license.txt.
7 * It is also available through the world-wide-web at this URL:
8 * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
9 * If you did not receive a copy of the license and are unable to
10 * obtain it through the world-wide-web, please send an email
11 * to license@woocommerce.com so we can send you a copy immediately.
12 *
13 * DISCLAIMER
14 *
15 * Do not edit or add to this file if you wish to upgrade WooCommerce Square to newer
16 * versions in the future. If you wish to customize WooCommerce Square for your
17 * needs please refer to https://docs.woocommerce.com/document/woocommerce-square/
18 *
19 * @author WooCommerce
20 * @copyright Copyright: (c) 2019, Automattic, Inc.
21 * @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
22 */
23
24 namespace WooCommerce\Square\Sync;
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Stepped Job abstract.
30 *
31 * Adds multi-step management to the job class.
32 *
33 * @since 2.0.0
34 */
35 abstract class Stepped_Job extends Job {
36
37
38 /** @var int attempts a step makes to verify zero counts before it proceeds without them */
39 const MAX_ZERO_VERIFICATION_ATTEMPTS = 3;
40
41
42 /**
43 * Executes the next step of this job.
44 *
45 * @since 2.0.0
46 *
47 * @return \stdClass the job object
48 */
49 public function run() {
50
51 parent::run();
52
53 if ( empty( $this->get_attr( 'next_steps' ) ) && empty( $this->get_attr( 'completed_steps' ) ) ) {
54 $this->assign_next_steps();
55 }
56
57 $this->do_next_step();
58
59 return $this->job;
60 }
61
62
63 /**
64 * Verifies which zero counts are real, applying this class's shared retry policy when Square
65 * cannot be asked.
66 *
67 * Wraps the two parts every step repeated: verify the zeros, and hold and retry a bounded number
68 * of times when verification is unavailable. Progress markers stay with the caller, because each
69 * step holds something different (a cursor, a watermark, a queue attribute) and flattening that in
70 * here would silently drop work.
71 *
72 * The return says which zeros may be written, and null says the caller must hold its own progress
73 * and return so the step runs again. An empty array is NOT a signal of failure: it also means the
74 * question was asked successfully and none of those zeros is real. A caller that needs to know the
75 * attempts ran out (to keep a watermark from advancing over a window it could not verify) must ask
76 * zero_verification_exhausted() rather than infer it from an empty array.
77 *
78 * @since 5.5.0
79 *
80 * @param string $step_name step name used for the attempt counters and messages
81 * @param string[] $zero_object_ids catalog object ids reporting a zero count
82 * @return string[]|null verified ids, or null when the caller should hold and retry
83 */
84 protected function resolve_zero_count_verification( $step_name, array $zero_object_ids ) {
85
86 $verified = Helper::get_catalog_objects_with_inventory_history( $zero_object_ids );
87
88 if ( null !== $verified ) {
89 $this->set_attr( 'zero_verification_exhausted_' . $step_name, false );
90 $this->clear_unverified_zero_count_attempts( $step_name );
91 return $verified;
92 }
93
94 if ( $this->should_retry_unverified_zero_counts( $step_name ) ) {
95 return null;
96 }
97
98 // Attempts spent. Nothing is verified, so no zero may be written, and the caller is told so it
99 // can leave any watermark alone: re-reading the same window later is what stops a genuine
100 // sellout from being skipped permanently.
101 // Persisted immediately rather than relying on a later attribute write to flush it: the
102 // watermark guard reads this, and a step that returns before its next set_attr() would
103 // otherwise leave the flag only in memory.
104 $this->set_attr( 'zero_verification_exhausted_' . $step_name, true );
105
106 return array();
107 }
108
109
110 /**
111 * Whether the last verification attempt for a step ran out of retries without an answer.
112 *
113 * Steps that advance a watermark must not move it over a window whose zero counts they could not
114 * verify, or a genuine sellout in that window is skipped for good. An empty verified list cannot
115 * answer this, since it also means "asked, and none of them were real".
116 *
117 * @since 5.5.0
118 *
119 * @param string $step_name step name used for the attempt counters
120 * @return bool
121 */
122 protected function zero_verification_exhausted( $step_name ) {
123
124 return (bool) $this->get_attr( 'zero_verification_exhausted_' . $step_name, false );
125 }
126
127
128 /**
129 * Decides how a step should react when Square's inventory history could not be read.
130 *
131 * A zero count cannot be classified as a real sellout or as a never counted item without that
132 * history, and writing it blind is the behavior SQUARE-145 fixed. The step therefore holds its
133 * progress and is run again by the job loop. Those retries follow the job's own loopback timing,
134 * so they are close together rather than spread over cycles: they cover a brief blip, not a long
135 * outage. Holding forever would keep a job alive without ever finishing, so after a bounded
136 * number of attempts the step proceeds with no zero verified, which skips only the zero writes,
137 * and records an alert so the outcome is never silent. Steps that own a watermark should also
138 * leave it alone on that path so the same window is read again later.
139 *
140 * @since 5.5.0
141 *
142 * @param string $step_name step name for the log and record messages
143 * @return bool true when the caller should stop and retry later, false when it should proceed
144 */
145 protected function should_retry_unverified_zero_counts( $step_name ) {
146
147 $attr = 'zero_verification_attempts_' . $step_name;
148 $attempts = (int) $this->get_attr( $attr, 0 );
149
150 if ( $attempts < self::MAX_ZERO_VERIFICATION_ATTEMPTS ) {
151
152 $this->set_attr( $attr, $attempts + 1 );
153
154 wc_square()->log( sprintf( 'Could not verify zero inventory counts during %1$s; holding this step and running it again (attempt %2$d of %3$d).', $step_name, $attempts + 1, self::MAX_ZERO_VERIFICATION_ATTEMPTS ) );
155
156 return true;
157 }
158
159 $this->set_attr( $attr, 0 );
160
161 // One alert per job AND at most one per window across jobs: interval polling creates a fresh
162 // job every cycle, so a job attribute alone would still add a record on every poll during a
163 // sustained outage and push other records out of the capped list.
164 if ( ! $this->get_attr( 'zero_verification_alert_recorded', false ) && ! get_transient( 'wc_square_zero_verification_alerted' ) ) {
165
166 $this->set_attr( 'zero_verification_alert_recorded', true );
167 set_transient( 'wc_square_zero_verification_alerted', 1, 6 * HOUR_IN_SECONDS );
168
169 Records::set_record(
170 array(
171 'type' => 'alert',
172 'message' => esc_html__( 'Square could not confirm which products are genuinely sold out, so their stock was left unchanged and the sync continued. Run the sync again once Square is responding normally.', 'woocommerce-square' ),
173 )
174 );
175 }
176
177 wc_square()->log( sprintf( 'Could not verify zero inventory counts during %s after %d attempts; continuing without writing any zero quantity.', $step_name, self::MAX_ZERO_VERIFICATION_ATTEMPTS ) );
178
179 return false;
180 }
181
182
183 /**
184 * Clears the unverified zero count retry counter after a successful verification.
185 *
186 * @since 5.5.0
187 */
188 protected function clear_unverified_zero_count_attempts( $step_name ) {
189
190 $attr = 'zero_verification_attempts_' . $step_name;
191
192 if ( $this->get_attr( $attr, 0 ) ) {
193 $this->set_attr( $attr, 0 );
194 }
195 }
196
197
198 /**
199 * Assigns the next steps needed for this sync job.
200 *
201 * Adds the next steps to the 'next_steps' attribute.
202 *
203 * @since 2.0.0
204 */
205 abstract protected function assign_next_steps();
206
207
208 /**
209 * Gets the next step in the sync process.
210 *
211 * @since 2.0.0
212 *
213 * @return string|null
214 */
215 protected function get_next_step() {
216
217 $next_steps = $this->get_next_steps();
218
219 return isset( $next_steps[0] ) ? $next_steps[0] : null;
220 }
221
222
223 /**
224 * Gets the next steps for the sync process.
225 *
226 * @since 2.0.0
227 *
228 * @return string[]
229 */
230 protected function get_next_steps() {
231
232 return $this->get_attr( 'next_steps' );
233 }
234
235
236 /**
237 * Performs the next step in the sync process.
238 *
239 * @since 2.0.0
240 */
241 protected function do_next_step() {
242 $max_retry = 3; // Maximum number of retries for rate limit errors.
243 $retry = $this->get_attr( 'retry', 0 ); // Number of retries for rate limit errors.
244
245 $next_step = $this->get_next_step();
246
247 if ( is_callable( array( $this, $next_step ) ) ) {
248
249 $this->start_step_cycle( $next_step );
250
251 try {
252
253 $this->$next_step();
254 $this->complete_step_cycle( $next_step );
255 $this->set_attr( 'retry', 0 ); // Reset retry count to 0 after successful step cycle.
256 } catch ( \Exception $exception ) {
257 $error_message = $exception->getMessage();
258 // If sync fail with rate limit error, retry the sync process after few seconds. (retry upto 3 times)
259 if ( false !== strpos( $error_message, 'RATE_LIMITED' ) && $retry < $max_retry ) {
260 wc_square()->log( 'Rate limit error detected, pausing sync process for few secs...' );
261 $this->set_attr( 'retry', $retry + 1 );
262 return;
263 }
264
265 $this->complete_step_cycle( $next_step, false, $exception->getMessage() );
266 $this->fail( $exception->getMessage() );
267 return;
268 }
269 }
270
271 if ( ! $this->get_next_step() ) {
272
273 $this->complete();
274 }
275 }
276
277
278 /**
279 * Records the beginning of a new step cycle, meaning a new loop on the job for a given step.
280 *
281 * @since 2.0.0
282 *
283 * @param string $step_name the step name
284 */
285 protected function start_step_cycle( $step_name ) {
286
287 $current_step_cycle = array(
288 'step_name' => $step_name,
289 'start_time' => microtime( true ),
290 );
291
292 wc_square()->log( "Starting step cycle: $step_name" );
293
294 $this->set_attr( 'current_step_cycle', $current_step_cycle );
295 }
296
297
298 /**
299 * Records the completion of a step cycle.
300 *
301 * @since 2.0.0
302 *
303 * @param string $step_name the step name
304 * @param bool $is_successful (optional) whether the step completion is from a success or not
305 * @param string $error_message (optional) error message to include with failed step log
306 */
307 protected function complete_step_cycle( $step_name, $is_successful = true, $error_message = '' ) {
308
309 $current_step_cycle = $this->get_attr( 'current_step_cycle', array() );
310
311 if ( ! empty( $current_step_cycle ) ) {
312
313 $current_step_cycle['end_time'] = microtime( true );
314 $current_step_cycle['runtime'] = number_format( $current_step_cycle['end_time'] - $current_step_cycle['start_time'], 2 ) . 's';
315 $current_step_cycle['success'] = true === $is_successful;
316
317 if ( true === $is_successful ) {
318
319 wc_square()->log( "Completed step cycle: $step_name ({$current_step_cycle['runtime']})" );
320
321 } else {
322
323 wc_square()->log( "Failed step cycle: $step_name ({$current_step_cycle['runtime']}) - $error_message" );
324 }
325
326 $completed_cycles = $this->get_attr( 'completed_step_cycles', array() );
327 $completed_cycles[] = $current_step_cycle;
328 $this->set_attr( 'completed_step_cycles', $completed_cycles );
329 }
330 }
331
332
333 /**
334 * Completes the specified step (if it's the next step).
335 *
336 * @since 2.0.0
337 *
338 * @param string $step_name
339 */
340 protected function complete_step( $step_name ) {
341
342 $next_steps = $this->get_next_steps();
343
344 if ( isset( $next_steps[0] ) && $step_name === $next_steps[0] ) {
345
346 $this->add_completed_step( $step_name );
347 array_shift( $next_steps );
348 $this->set_attr( 'next_steps', $next_steps );
349 }
350 }
351
352
353 /**
354 * Adds a step to the completed steps array.
355 *
356 * @since 2.0.0
357 *
358 * @param string $step_name
359 */
360 protected function add_completed_step( $step_name ) {
361
362 if ( empty( $step_name ) ) {
363 return;
364 }
365
366 $completed_steps = $this->get_attr( 'completed_steps', array() );
367
368 $completed_steps[] = array(
369 'name' => $step_name,
370 'completion_time' => current_time( 'mysql' ),
371 );
372
373 $this->set_attr( 'completed_steps', $completed_steps );
374
375 $update_data = $this->get_step_update_data( $step_name );
376
377 wc_square()->log( 'Completed job step: ' . $step_name . $update_data );
378 }
379
380 /**
381 * Get step update data like count of synced products or categories.
382 *
383 * @param string $step_name Step name.
384 * @return string
385 */
386 protected function get_step_update_data( $step_name ) {
387 $update_data = '';
388 $count = $this->get_attr( $step_name . '_count', 0 );
389 switch ( $step_name ) {
390 // Product Import.
391 case 'import_products':
392 $imported = count( $this->get_attr( 'processed_product_ids', array() ) );
393 $updated = count( $this->get_attr( 'updated_product_ids', array() ) );
394 $skipped = count( $this->get_attr( 'skipped_products', array() ) );
395 $update_data = sprintf( ' (Imported products: %d, Updated products: %d, Skipped products: %d)', $imported, $updated, $skipped );
396 break;
397
398 case 'import_inventory':
399 $update_data = sprintf( ' (Synced products: %d)', $count );
400 break;
401
402 // Manual Sync.
403 case 'validate_products':
404 $count = count( $this->get_attr( 'validated_product_ids', array() ) );
405 $update_data = sprintf( ' (Validated products: %d)', $count );
406 break;
407
408 case 'extract_category_ids':
409 $count = count( $this->get_attr( 'category_ids', array() ) );
410 $update_data = sprintf( ' (Extracted categories: %d)', $count );
411 break;
412
413 case 'refresh_category_mappings':
414 $mapped_cat = count( $this->get_attr( 'mapped_categories', array() ) );
415 $unmapped_cat = count( $this->get_attr( 'unmapped_categories', array() ) );
416 $update_data = sprintf( ' (Mapped categories: %d, Unmapped categories: %d)', $mapped_cat, $unmapped_cat );
417 break;
418
419 case 'query_unmapped_categories':
420 $mapped_cat = count( $this->get_attr( 'mapped_categories', array() ) );
421 $update_data = sprintf( ' (Total mapped categories: %d)', $mapped_cat );
422 break;
423
424 case 'upsert_categories':
425 $count = count( $this->get_attr( 'category_ids', array() ) );
426 $update_data = sprintf( ' (Upserted categories: %d)', $count );
427 break;
428
429 case 'update_matched_products':
430 $count = count( $this->get_attr( 'processed_product_ids', array() ) );
431 $update_data = sprintf( ' (Synced matched products: %d)', $count );
432 break;
433
434 case 'search_matched_products':
435 case 'square_sor_sync':
436 $count = count( $this->get_attr( 'processed_product_ids', array() ) );
437 $update_data = sprintf( ' (Synced products: %d)', $count );
438 break;
439
440 case 'upsert_new_products':
441 $count = count( $this->get_attr( 'inventory_push_product_ids', array() ) );
442 $update_data = sprintf( ' (Newly upserted products: %d)', $count );
443 break;
444
445 case 'push_inventory':
446 $update_data = sprintf( ' (Synced products: %d)', $count );
447 break;
448
449 case 'pull_inventory':
450 $count = count( $this->get_attr( 'processed_square_variation_ids', array() ) );
451 $update_data = sprintf( ' (Synced products: %d)', $count );
452 break;
453
454 // Interval Polling.
455 case 'update_category_data':
456 $update_data = sprintf( ' (Updated categories: %d)', $count );
457 break;
458
459 case 'update_product_data':
460 $update_data = sprintf( ' (Updated products: %d)', $count );
461 break;
462
463 case 'update_inventory_counts':
464 $update_data = sprintf( ' (Synced products: %d)', $count );
465 break;
466
467 default:
468 break;
469 }
470
471 return $update_data;
472 }
473 }
474