PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/Sync/Integrity_Digest.php +196 -102 1.10.18 → 1.10.17 View file →
@@ -19,9 +19,9 @@
19 19 * Hash-backed range-checksum support: stored per-record content digests.
20 20 *
21 21 * STORES a digest of each product/variation's raw DB row, marked dirty by the
22 22 * same save/delete hooks class-change-log.php uses and written at the request
23 - * boundary (see $pending), so the integrity scan
23 + * boundary (see $pending_digests), so the integrity scan
24 24 * can compare — entirely in SQL — the aggregate of CURRENT raw-row digests
25 25 * against the aggregate of STORED digests per id-range bucket. If hooks
26 26 * fired for every write, stored == current (and sequence-log already
27 27 * reported the change); a bucket mismatch therefore means exactly "content
@@ -81,32 +81,38 @@
81 81 */
82 82 private Digest_Index $index;
83 83
84 84 /**
85 - * Digests owed but not yet written, keyed by blog, type and id.
85 + * Order and customer digests owed but not yet written, keyed "type:id".
86 86 *
87 87 * A stored digest is a pure function of the settled record, so only the
88 88 * LAST upsert in a request carries information — yet one Store API checkout
89 89 * ran the order INSERT…SELECT eleven times (35 ms) and, with account
90 90 * creation, the customer one six more times (measured 2026-09-03 on
91 - * dev-next). Upserts land on {@see flush_pending_digests()}: at shutdown
92 - * and before {@see Digest_Index::read_digests()}, so the pull lane never
93 - * stamps a stale `_rxdb_digest`. Product and variation digests ride the
94 - * same queue: `wc_reduce_stock_levels()` saves the quantity, then the stock
95 - * status — two INSERT…SELECT statements per purchased product at checkout
96 - * (measured 2026-09-03). The v2 write lane reads digests through that same
97 - * read method, so its serializer still stamps a fresh `_rxdb_digest`.
91 + * dev-next). Upserts land on {@see flush_pending_digests()}: at `shutdown`
92 + * (last, after WooCommerce's own customer save at 10 and session save at 20,
93 + * whose `woocommerce_update_customer` would otherwise queue after the only
94 + * flush), before any {@see Digest_Index::read_digests()} so the pull lane
95 + * never stamps a stale `_rxdb_digest`, and whenever the queue reaches
96 + * {@see PENDING_DIGEST_FLUSH_THRESHOLD} distinct records (a bulk import
97 + * coalesces nothing, so it must not accumulate). Once the shutdown flush
98 + * has run, later saves write immediately. Static so the read path can
99 + * flush without holding the observer instance; each entry keeps its blog id
100 + * so a multisite `switch_to_blog()` between save and flush still writes the
101 + * originating site's table. Product and variation digests ride the same
102 + * queue: WooCommerce saves a product more than once per request too
103 + * (`wc_reduce_stock_levels()` saves the quantity, then the stock status —
104 + * two INSERT…SELECT statements per purchased product at checkout, measured
105 + * 2026-09-03), and the v2 write lane reads digests back through
106 + * {@see Digest_Index::read_digests()}, which flushes first, so the
107 + * serializer still stamps the fresh `_rxdb_digest` in the same request.
98 108 *
99 - * Static so the read path can flush without holding the observer. The first
100 - * queuing instance binds the writer, including after shutdown; all instances
101 - * write the same table. An empty shutdown uses a default instance instead.
102 - * See Request_Write_Queue for the queue mechanics.
109 + * @var array<string, array{0: int, 1: string, 2: int}> "blog:type:id" => [blog, type, id]
103 110 */
104 - private static ?Request_Write_Queue $pending = null;
111 + private static array $pending_digests = array();
105 112
106 113 /**
107 - * The most distinct records held; the next distinct record flushes them first.
108 - * Sized for the
114 + * Flush the queue when it holds this many distinct records. Sized for the
109 115 * realistic per-request maximum (a checkout touches an order and a customer;
110 116 * a REST batch a few dozen records) while keeping a WP-CLI import's deferred
111 117 * SQL and memory bounded.
112 118 */
@@ -111,8 +117,14 @@
111 117 * SQL and memory bounded.
112 118 */
113 119 public const PENDING_DIGEST_FLUSH_THRESHOLD = 50;
114 120
121 + /** The instance that first queued a digest; the flush writes through its Digest_Index. */
122 + private static ?Integrity_Digest $flusher = null;
123 +
124 + /** Set by the shutdown flush; afterwards saves are written immediately. */
125 + private static bool $shutdown_flushed = false;
126 +
115 127 public function __construct( ?Digest_Index $index = null ) {
116 128 $this->index = $index ?? new Digest_Index();
117 129 }
118 130
@@ -196,9 +208,9 @@
196 208 // order's digest is never recreated and integrity scans treat it as
197 209 // deleted forever.
198 210 add_action( 'woocommerce_untrash_order', array( $this, 'record_order_untrashed' ), 10, 1 );
199 211 // Request boundary for the coalesced digest upserts (see
200 - // $pending). LAST on shutdown: WooCommerce saves the customer at
212 + // $pending_digests). LAST on shutdown: WooCommerce saves the customer at
201 213 // 10 and the session at 20. Zero accepted args: do_action( 'shutdown' )
202 214 // passes an empty string otherwise.
203 215 add_action( 'shutdown', array( __CLASS__, 'flush_pending_digests_at_shutdown' ), PHP_INT_MAX, 0 );
204 216 }
@@ -353,40 +365,38 @@
353 365 /**
354 366 * Customer digest maintenance (ADR 0015, Leg-3 phase 7) — every WordPress
355 367 * user is a POS customer, so saves and role changes always upsert.
356 368 */
357 - /** Owe the customer's digest; it is written once, on flush (see $pending). */
369 + /** Owe the customer's digest; it is written once, on flush (see $pending_digests). */
358 370 public function record_customer_saved( int $user_id ): void {
359 - $this->defer( self::pending_type( 'customers' ), $user_id );
371 + $this->defer( 'customer', $user_id );
360 372 }
361 373
362 374 /**
363 375 * Queue one digest upsert, or write it now if the boundary has passed.
364 376 *
365 - * @param string $type Registry digest object-types key.
377 + * @param string $type 'order' or 'customer'.
366 378 * @param int $id Record id.
367 379 */
368 380 private function defer( string $type, int $id ): void {
369 - self::queue( $this )->owe( $type, $id );
370 - }
371 -
372 - /** Bind the first queuing instance, or a default for an empty shutdown. */
373 - private static function queue( ?self $writer = null ): Request_Write_Queue {
374 - if ( null === self::$pending ) {
375 - $writer = $writer ?? new self();
376 - self::$pending = new Request_Write_Queue(
377 - self::PENDING_DIGEST_FLUSH_THRESHOLD,
378 - function ( $type, $id ) use ( $writer ): void {
379 - $writer->upsert_pending( $type, $id );
380 - }
381 - );
381 + if ( self::$shutdown_flushed ) {
382 + // A save triggered by another shutdown handler (WooCommerce saves the
383 + // customer at priority 10): nothing will flush again, so write now.
384 + $this->upsert_pending( $type, $id );
385 + return;
382 386 }
383 - return self::$pending;
387 + if ( null === self::$flusher ) {
388 + self::$flusher = $this;
389 + }
390 + $blog = get_current_blog_id();
391 + self::$pending_digests[ self::pending_key( $type, $id ) ] = array( $blog, $type, $id );
392 + if ( \count( self::$pending_digests ) >= self::PENDING_DIGEST_FLUSH_THRESHOLD ) {
393 + self::flush_pending_digests();
394 + }
384 395 }
385 396
386 - /** One queue discriminator per digest id-space, including shared product/variation ids. */
387 - private static function pending_type( string $collection ): string {
388 - return implode( ',', Collections::row( $collection )['digest']['object_types'] );
397 + private static function pending_key( string $type, int $id ): string {
398 + return get_current_blog_id() . ':' . $type . ':' . $id;
389 399 }
390 400
391 401 /** One queued upsert, under the observer's fail-open posture. */
392 402 private function upsert_pending( string $type, int $id ): void {
@@ -391,15 +401,16 @@
391 401 /** One queued upsert, under the observer's fail-open posture. */
392 402 private function upsert_pending( string $type, int $id ): void {
393 403 $this->observe(
394 404 function () use ( $type, $id ): void {
395 - foreach ( Collections::with( 'digest' ) as $collection => $row ) {
396 - if ( self::pending_type( $collection ) === $type ) {
397 - $this->upsert_for( $row['digest']['id_space'], $id );
398 - return;
399 - }
405 + if ( 'customer' === $type ) {
406 + $this->upsert_customer_digest( $id );
407 + } elseif ( 'order' === $type ) {
408 + $this->upsert_order_digest( $id );
409 + } else {
410 + // 'post' (product or variation): the SQL derives the stored type from the row.
411 + $this->upsert_digest( $id );
400 412 }
401 - Logger::warning( 'WCPOS sync: no digest collection matches queued type: ' . $type );
402 413 }
403 414 );
404 415 }
405 416
@@ -406,9 +417,9 @@
406 417 /**
407 418 * Write every owed digest.
408 419 *
409 420 * Called from the shutdown flush, from {@see Digest_Index::read_digests()}
410 - * before it reads, and before a new record exceeds queue capacity. Writes go
421 + * before it reads, and when the queue reaches its threshold. Writes go
411 422 * through the instance that first queued (so an injected Digest_Index is
412 423 * honoured) and under the blog each entry was recorded on. Each upsert keeps
413 424 * the observer's fail-open posture: a failure is logged and the scan
414 425 * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
@@ -413,11 +424,28 @@
413 424 * the observer's fail-open posture: a failure is logged and the scan
414 425 * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
415 426 */
416 427 public static function flush_pending_digests(): void {
417 - if ( null !== self::$pending ) {
418 - self::$pending->flush();
428 + if ( array() === self::$pending_digests ) {
429 + return;
419 430 }
431 + $pending = self::$pending_digests;
432 + self::$pending_digests = array();
433 + $digest = self::$flusher ?? new self();
434 + foreach ( $pending as $entry ) {
435 + list( $blog, $type, $id ) = $entry;
436 + $switch = is_multisite() && get_current_blog_id() !== (int) $blog;
437 + if ( $switch ) {
438 + switch_to_blog( (int) $blog );
439 + }
440 + try {
441 + $digest->upsert_pending( (string) $type, (int) $id );
442 + } finally {
443 + if ( $switch ) {
444 + restore_current_blog();
445 + }
446 + }
447 + }
420 448 }
421 449
422 450 /**
423 451 * The `shutdown` callback: flush, then write every later save immediately.
@@ -422,24 +450,33 @@
422 450 /**
423 451 * The `shutdown` callback: flush, then write every later save immediately.
424 452 */
425 453 public static function flush_pending_digests_at_shutdown(): void {
426 - self::queue()->flush_at_shutdown();
454 + self::$shutdown_flushed = true;
455 + self::flush_pending_digests();
427 456 }
428 457
429 458 /**
430 459 * Discard per-request coalescing state. Tests only: the PHPUnit process
431 - * never reaches `shutdown`, so the static queue would
460 + * never reaches `shutdown`, so the static queue, flusher and flag would
432 461 * leak between test cases otherwise.
433 462 *
434 463 * @internal
435 464 */
436 465 public static function reset_request_state(): void {
437 - self::$pending = null;
466 + self::$pending_digests = array();
467 + self::$flusher = null;
468 + self::$shutdown_flushed = false;
438 469 }
439 470
440 471 public function record_customer_deleted( int $user_id ): void {
441 - $this->delete_for( 'customers', $user_id );
472 + // A pending upsert for a record that is leaving must not be written after the fact.
473 + unset( self::$pending_digests[ self::pending_key( 'customer', $user_id ) ] );
474 + $this->observe(
475 + function () use ( $user_id ): void {
476 + $this->delete_customer_digest( $user_id );
477 + }
478 + );
442 479 }
443 480
444 481 /**
445 482 * Observation hooks must never break the host write that fired them: a
@@ -457,38 +494,85 @@
457 494 Logger::error( 'Sync digest observer failed (sync will self-heal via scan/rebuild): ' . $e->getMessage() );
458 495 }
459 496 }
460 497
498 + private function delete_customer_digest( int $user_id ): void {
499 + global $wpdb;
500 + $deleted = $wpdb->delete(
501 + $this->table_name(),
502 + array(
503 + 'object_type' => 'customer',
504 + 'object_id' => $user_id,
505 + ),
506 + array( '%s', '%d' )
507 + );
508 + if ( false === $deleted ) {
509 + throw new RuntimeException( 'delete stored customer digest failed: ' . $wpdb->last_error );
510 + }
511 + }
512 +
461 513 /**
462 514 * Order digest maintenance (ADR 0015, Leg-3 phase 7). The WC order hooks are storage-agnostic (fire
463 515 * under HPOS AND CPT); the digest SQL's `type='shop_order'` filter makes the upsert a no-op for any
464 516 * non-order, so no type re-check is needed here.
465 517 */
466 - /** Owe the order's digest; it is written once, on flush (see $pending). */
518 + /** Owe the order's digest; it is written once, on flush (see $pending_digests). */
467 519 public function record_order_saved( int $order_id ): void {
468 - $this->defer( self::pending_type( 'orders' ), $order_id );
520 + $this->defer( 'order', $order_id );
469 521 }
470 522
471 523 public function record_order_deleted( int $order_id ): void {
472 - $this->delete_for( 'orders', $order_id );
524 + // A pending upsert for a record that is leaving must not be written after the fact.
525 + unset( self::$pending_digests[ self::pending_key( 'order', $order_id ) ] );
526 + $this->observe(
527 + function () use ( $order_id ): void {
528 + $this->delete_order_digest( $order_id );
529 + }
530 + );
473 531 }
474 532
475 - /**
476 - * Order analogue of {@see upsert_customer_digest} (HPOS or CPT).
477 - *
478 - * @deprecated Use record_order_saved().
479 - */
533 + private function delete_order_digest( int $order_id ): void {
534 + global $wpdb;
535 + $deleted = $wpdb->delete(
536 + $this->table_name(),
537 + array(
538 + 'object_type' => 'order',
539 + 'object_id' => $order_id,
540 + ),
541 + array( '%s', '%d' )
542 + );
543 + if ( false === $deleted ) {
544 + throw new RuntimeException( 'delete stored order digest failed: ' . $wpdb->last_error );
545 + }
546 + }
547 +
548 + /** Order analogue of {@see upsert_customer_digest}: compute + store one order's digest (HPOS or CPT). */
480 549 public function upsert_order_digest( int $order_id ): void {
481 - $this->upsert_for( 'orders', $order_id );
550 + global $wpdb;
551 + $started = microtime( true );
552 + $this->index->raise_group_concat_max_len();
553 + $result = $wpdb->query(
554 + $wpdb->prepare(
555 + 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
556 + . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
557 + . ' FROM (' . $this->index->order_digest_select_sql( '{id} = %d' ) . ') t'
558 + . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
559 + $order_id
560 + )
561 + );
562 + self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
563 + if ( false === $result ) {
564 + throw new RuntimeException( 'upsert stored order digest failed: ' . $wpdb->last_error );
565 + }
482 566 }
483 567
484 568 /**
485 569 * Owe the product's or variation's digest; it is written once, on flush (see
486 - * $pending). Both share the registry's queue key: the upsert's SQL
570 + * $pending_digests). The queue type is 'post' for both: the upsert's SQL
487 571 * derives the stored object_type from the row, so nothing here needs to.
488 572 */
489 573 public function record_post_saved( int $post_id ): void {
490 - $this->defer( self::pending_type( 'products' ), $post_id );
574 + $this->defer( 'post', $post_id );
491 575 }
492 576
493 577 public function record_post_untrashed( int $post_id ): void {
494 578 $post_type = get_post_type( $post_id );
@@ -505,39 +589,43 @@
505 589 $post_type = get_post_type( $post_id );
506 590 if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
507 591 return;
508 592 }
509 - $this->delete_for( 'products', $post_id, 'product_variation' === $post_type );
593 + // A pending upsert for a record that is leaving must not be written after the fact.
594 + unset( self::$pending_digests[ self::pending_key( 'post', $post_id ) ] );
595 + $this->observe(
596 + function () use ( $post_id, $post_type ): void {
597 + $this->delete_post_digest( $post_id, $post_type );
598 + }
599 + );
510 600 }
511 601
512 - /** Cancel an owed upsert and remove the registry-selected stored row. */
513 - private function delete_for( string $collection, int $id, bool $child = false ): void {
514 - if ( null !== self::$pending ) {
515 - self::$pending->drop( self::pending_type( $collection ), $id );
602 + /**
603 + * Remove a product/variation digest row after a hooked delete.
604 + *
605 + * A hooked delete removes the stored row so stored == current again.
606 + * Only a hook-BYPASSING delete leaves an orphan digest behind, which
607 + * the scan reports as a mismatch (stored side carries a row the
608 + * current side lacks) and the drill-down labels status=deleted.
609 + *
610 + * @param int $post_id The deleted post id.
611 + * @param string $post_type Its post type (product | product_variation).
612 + */
613 + private function delete_post_digest( int $post_id, string $post_type ): void {
614 + global $wpdb;
615 + $started = microtime( true );
616 + $deleted = $wpdb->delete(
617 + $this->table_name(),
618 + array(
619 + 'object_type' => 'product_variation' === $post_type ? 'variation' : 'product',
620 + 'object_id' => $post_id,
621 + ),
622 + array( '%s', '%d' )
623 + );
624 + self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
625 + if ( false === $deleted ) {
626 + throw new RuntimeException( 'delete stored digest failed: ' . $wpdb->last_error );
516 627 }
517 - $this->observe(
518 - function () use ( $collection, $id, $child ): void {
519 - global $wpdb;
520 - $row = Collections::row( $collection );
521 - $digest = $row['digest'];
522 - $started = microtime( true );
523 - $deleted = $wpdb->delete(
524 - $this->table_name(),
525 - array(
526 - 'object_type' => $child ? $digest['child_type'] : $row['object_type'],
527 - 'object_id' => $id,
528 - ),
529 - array( '%s', '%d' )
530 - );
531 - if ( 'products' === $digest['id_space'] ) {
532 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
533 - }
534 - if ( false === $deleted ) {
535 - $label = $digest['label'];
536 - throw new RuntimeException( 'delete stored ' . $label . 'digest failed: ' . $wpdb->last_error );
537 - }
538 - }
539 - );
540 628 }
541 629
542 630 /**
543 631 * One statement: the digest is computed in SQL from the raw row and
@@ -542,12 +630,26 @@
542 630 /**
543 631 * One statement: the digest is computed in SQL from the raw row and
544 632 * upserted in the same statement — PHP never materializes the value.
545 633 * No-op for rows outside the live predicate (the delete hook owns those).
546 - * @deprecated Use record_post_saved().
547 634 */
548 635 public function upsert_digest( int $post_id ): void {
549 - $this->upsert_for( 'products', $post_id );
636 + global $wpdb;
637 + // Time from BEFORE the session setup so timing.digest_ms covers ALL digest hook work
638 + // (the raise runs inside the save hook — codex P3).
639 + $started = microtime( true );
640 + $this->index->raise_group_concat_max_len();
641 + $this->query_with_retry(
642 + $wpdb->prepare(
643 + 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
644 + . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
645 + . ' FROM (' . $this->index->row_digest_select_sql( 'p.ID = %d' ) . ') t'
646 + . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
647 + $post_id
648 + ),
649 + 'upsert stored digest failed: ',
650 + $started
651 + );
550 652 }
551 653
552 654 /**
553 655 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
@@ -552,19 +654,11 @@
552 654 /**
553 655 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
554 656 * compute and store one WordPress user's customer digest in a single
555 657 * INSERT…SELECT. Only the delete hook removes it.
556 - * @deprecated Use record_customer_saved().
557 658 */
558 659 public function upsert_customer_digest( int $user_id ): void {
559 - $this->upsert_for( 'customers', $user_id );
560 - }
561 -
562 - /** Compute and store one row using its id-space's canonical SELECT and retry policy. */
563 - private function upsert_for( string $collection, int $id ): void {
564 660 global $wpdb;
565 - $digest = Collections::row( $collection )['digest'];
566 - $label = $digest['label'];
567 661 $started = microtime( true );
568 662 $this->index->raise_group_concat_max_len();
569 663 $this->query_with_retry(
570 664 $wpdb->prepare(
@@ -569,13 +663,13 @@
569 663 $this->query_with_retry(
570 664 $wpdb->prepare(
571 665 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
572 666 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
573 - . ' FROM (' . $this->index->{$digest['select']}( $digest['id_column'] . ' = %d' ) . ') t'
667 + . ' FROM (' . $this->index->customer_digest_select_sql( 'u.ID = %d' ) . ') t'
574 668 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
575 - $id
669 + $user_id
576 670 ),
577 - 'upsert stored ' . $label . 'digest failed: ',
671 + 'upsert stored customer digest failed: ',
578 672 $started
579 673 );
580 674 }
581 675