PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Sync/Integrity_Digest.php +117 -196 1.10.81.10.19 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_digests), so the integrity scan
23 + * boundary (see $pending), 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,38 +81,32 @@
81 81 */
82 82 private Digest_Index $index;
83 83
84 84 /**
85 - * Order and customer digests owed but not yet written, keyed "type:id".
85 + * Digests owed but not yet written, keyed by blog, type and 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 - * (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.
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`.
108 98 *
109 - * @var array<string, array{0: int, 1: string, 2: int}> "blog:type:id" => [blog, type, id]
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.
110 103 */
111 - private static array $pending_digests = array();
104 + private static ?Request_Write_Queue $pending = null;
112 105
113 106 /**
114 - * Flush the queue when it holds this many distinct records. Sized for the
107 + * The most distinct records held; the next distinct record flushes them first.
108 + * Sized for the
115 109 * realistic per-request maximum (a checkout touches an order and a customer;
116 110 * a REST batch a few dozen records) while keeping a WP-CLI import's deferred
117 111 * SQL and memory bounded.
118 112 */
@@ -117,14 +111,8 @@
117 111 * SQL and memory bounded.
118 112 */
119 113 public const PENDING_DIGEST_FLUSH_THRESHOLD = 50;
120 114
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 -
127 115 public function __construct( ?Digest_Index $index = null ) {
128 116 $this->index = $index ?? new Digest_Index();
129 117 }
130 118
@@ -208,9 +196,9 @@
208 196 // order's digest is never recreated and integrity scans treat it as
209 197 // deleted forever.
210 198 add_action( 'woocommerce_untrash_order', array( $this, 'record_order_untrashed' ), 10, 1 );
211 199 // Request boundary for the coalesced digest upserts (see
212 - // $pending_digests). LAST on shutdown: WooCommerce saves the customer at
200 + // $pending). LAST on shutdown: WooCommerce saves the customer at
213 201 // 10 and the session at 20. Zero accepted args: do_action( 'shutdown' )
214 202 // passes an empty string otherwise.
215 203 add_action( 'shutdown', array( __CLASS__, 'flush_pending_digests_at_shutdown' ), PHP_INT_MAX, 0 );
216 204 }
@@ -294,8 +282,23 @@
294 282 return $registered;
295 283 }
296 284
297 285 /**
286 + * Detach THE digest stamper from both served read lanes.
287 + *
288 + * The teardown twin of {@see register_proxy_digest_stampers()}, matching the
289 + * `unregister_*` seams {@see Revision} and {@see Proxy_Uuid_Stamper} already
290 + * expose. `Augmentation_Pipeline::reset()` only removes the projections the
291 + * pipeline itself installed, so without this a caller that installs the real
292 + * pipeline — a test wiring the production read lane — cannot unwind it and
293 + * leaks this filter into everything that runs after it.
294 + */
295 + public static function unregister_proxy_digest_stampers(): void {
296 + remove_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_digests' ), 10 );
297 + remove_filter( 'woocommerce_pos_sync_order_pull_payloads', array( __CLASS__, 'stamp_digests' ), 10 );
298 + }
299 +
300 + /**
298 301 * Attach each served record's stored 64-bit digest as a top-level `_rxdb_digest`
299 302 * string, so the client seeds its existence-reconcile manifest (ADR 0014 Leg 3)
300 303 * as records flow through the NORMAL pull — no separate fetch. The client reads
301 304 * it into the sidecar manifest; it is NOT persisted into the document. A record
@@ -350,38 +353,40 @@
350 353 /**
351 354 * Customer digest maintenance (ADR 0015, Leg-3 phase 7) — every WordPress
352 355 * user is a POS customer, so saves and role changes always upsert.
353 356 */
354 - /** Owe the customer's digest; it is written once, on flush (see $pending_digests). */
357 + /** Owe the customer's digest; it is written once, on flush (see $pending). */
355 358 public function record_customer_saved( int $user_id ): void {
356 - $this->defer( 'customer', $user_id );
359 + $this->defer( self::pending_type( 'customers' ), $user_id );
357 360 }
358 361
359 362 /**
360 363 * Queue one digest upsert, or write it now if the boundary has passed.
361 364 *
362 - * @param string $type 'order' or 'customer'.
365 + * @param string $type Registry digest object-types key.
363 366 * @param int $id Record id.
364 367 */
365 368 private function defer( string $type, int $id ): void {
366 - if ( self::$shutdown_flushed ) {
367 - // A save triggered by another shutdown handler (WooCommerce saves the
368 - // customer at priority 10): nothing will flush again, so write now.
369 - $this->upsert_pending( $type, $id );
370 - return;
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 + );
371 382 }
372 - if ( null === self::$flusher ) {
373 - self::$flusher = $this;
374 - }
375 - $blog = get_current_blog_id();
376 - self::$pending_digests[ self::pending_key( $type, $id ) ] = array( $blog, $type, $id );
377 - if ( \count( self::$pending_digests ) >= self::PENDING_DIGEST_FLUSH_THRESHOLD ) {
378 - self::flush_pending_digests();
379 - }
383 + return self::$pending;
380 384 }
381 385
382 - private static function pending_key( string $type, int $id ): string {
383 - return get_current_blog_id() . ':' . $type . ':' . $id;
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'] );
384 389 }
385 390
386 391 /** One queued upsert, under the observer's fail-open posture. */
387 392 private function upsert_pending( string $type, int $id ): void {
@@ -386,16 +391,15 @@
386 391 /** One queued upsert, under the observer's fail-open posture. */
387 392 private function upsert_pending( string $type, int $id ): void {
388 393 $this->observe(
389 394 function () use ( $type, $id ): void {
390 - if ( 'customer' === $type ) {
391 - $this->upsert_customer_digest( $id );
392 - } elseif ( 'order' === $type ) {
393 - $this->upsert_order_digest( $id );
394 - } else {
395 - // 'post' (product or variation): the SQL derives the stored type from the row.
396 - $this->upsert_digest( $id );
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 + }
397 400 }
401 + Logger::warning( 'WCPOS sync: no digest collection matches queued type: ' . $type );
398 402 }
399 403 );
400 404 }
401 405
@@ -402,9 +406,9 @@
402 406 /**
403 407 * Write every owed digest.
404 408 *
405 409 * Called from the shutdown flush, from {@see Digest_Index::read_digests()}
406 - * before it reads, and when the queue reaches its threshold. Writes go
410 + * before it reads, and before a new record exceeds queue capacity. Writes go
407 411 * through the instance that first queued (so an injected Digest_Index is
408 412 * honoured) and under the blog each entry was recorded on. Each upsert keeps
409 413 * the observer's fail-open posture: a failure is logged and the scan
410 414 * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
@@ -409,28 +413,11 @@
409 413 * the observer's fail-open posture: a failure is logged and the scan
410 414 * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
411 415 */
412 416 public static function flush_pending_digests(): void {
413 - if ( array() === self::$pending_digests ) {
414 - return;
417 + if ( null !== self::$pending ) {
418 + self::$pending->flush();
415 419 }
416 - $pending = self::$pending_digests;
417 - self::$pending_digests = array();
418 - $digest = self::$flusher ?? new self();
419 - foreach ( $pending as $entry ) {
420 - list( $blog, $type, $id ) = $entry;
421 - $switch = is_multisite() && get_current_blog_id() !== (int) $blog;
422 - if ( $switch ) {
423 - switch_to_blog( (int) $blog );
424 - }
425 - try {
426 - $digest->upsert_pending( (string) $type, (int) $id );
427 - } finally {
428 - if ( $switch ) {
429 - restore_current_blog();
430 - }
431 - }
432 - }
433 420 }
434 421
435 422 /**
436 423 * The `shutdown` callback: flush, then write every later save immediately.
@@ -435,33 +422,24 @@
435 422 /**
436 423 * The `shutdown` callback: flush, then write every later save immediately.
437 424 */
438 425 public static function flush_pending_digests_at_shutdown(): void {
439 - self::$shutdown_flushed = true;
440 - self::flush_pending_digests();
426 + self::queue()->flush_at_shutdown();
441 427 }
442 428
443 429 /**
444 430 * Discard per-request coalescing state. Tests only: the PHPUnit process
445 - * never reaches `shutdown`, so the static queue, flusher and flag would
431 + * never reaches `shutdown`, so the static queue would
446 432 * leak between test cases otherwise.
447 433 *
448 434 * @internal
449 435 */
450 436 public static function reset_request_state(): void {
451 - self::$pending_digests = array();
452 - self::$flusher = null;
453 - self::$shutdown_flushed = false;
437 + self::$pending = null;
454 438 }
455 439
456 440 public function record_customer_deleted( int $user_id ): void {
457 - // A pending upsert for a record that is leaving must not be written after the fact.
458 - unset( self::$pending_digests[ self::pending_key( 'customer', $user_id ) ] );
459 - $this->observe(
460 - function () use ( $user_id ): void {
461 - $this->delete_customer_digest( $user_id );
462 - }
463 - );
441 + $this->delete_for( 'customers', $user_id );
464 442 }
465 443
466 444 /**
467 445 * Observation hooks must never break the host write that fired them: a
@@ -479,85 +457,38 @@
479 457 Logger::error( 'Sync digest observer failed (sync will self-heal via scan/rebuild): ' . $e->getMessage() );
480 458 }
481 459 }
482 460
483 - private function delete_customer_digest( int $user_id ): void {
484 - global $wpdb;
485 - $deleted = $wpdb->delete(
486 - $this->table_name(),
487 - array(
488 - 'object_type' => 'customer',
489 - 'object_id' => $user_id,
490 - ),
491 - array( '%s', '%d' )
492 - );
493 - if ( false === $deleted ) {
494 - throw new RuntimeException( 'delete stored customer digest failed: ' . $wpdb->last_error );
495 - }
496 - }
497 -
498 461 /**
499 462 * Order digest maintenance (ADR 0015, Leg-3 phase 7). The WC order hooks are storage-agnostic (fire
500 463 * under HPOS AND CPT); the digest SQL's `type='shop_order'` filter makes the upsert a no-op for any
501 464 * non-order, so no type re-check is needed here.
502 465 */
503 - /** Owe the order's digest; it is written once, on flush (see $pending_digests). */
466 + /** Owe the order's digest; it is written once, on flush (see $pending). */
504 467 public function record_order_saved( int $order_id ): void {
505 - $this->defer( 'order', $order_id );
468 + $this->defer( self::pending_type( 'orders' ), $order_id );
506 469 }
507 470
508 471 public function record_order_deleted( int $order_id ): void {
509 - // A pending upsert for a record that is leaving must not be written after the fact.
510 - unset( self::$pending_digests[ self::pending_key( 'order', $order_id ) ] );
511 - $this->observe(
512 - function () use ( $order_id ): void {
513 - $this->delete_order_digest( $order_id );
514 - }
515 - );
472 + $this->delete_for( 'orders', $order_id );
516 473 }
517 474
518 - private function delete_order_digest( int $order_id ): void {
519 - global $wpdb;
520 - $deleted = $wpdb->delete(
521 - $this->table_name(),
522 - array(
523 - 'object_type' => 'order',
524 - 'object_id' => $order_id,
525 - ),
526 - array( '%s', '%d' )
527 - );
528 - if ( false === $deleted ) {
529 - throw new RuntimeException( 'delete stored order digest failed: ' . $wpdb->last_error );
530 - }
531 - }
532 -
533 - /** Order analogue of {@see upsert_customer_digest}: compute + store one order's digest (HPOS or CPT). */
475 + /**
476 + * Order analogue of {@see upsert_customer_digest} (HPOS or CPT).
477 + *
478 + * @deprecated Use record_order_saved().
479 + */
534 480 public function upsert_order_digest( int $order_id ): void {
535 - global $wpdb;
536 - $started = microtime( true );
537 - $this->index->raise_group_concat_max_len();
538 - $result = $wpdb->query(
539 - $wpdb->prepare(
540 - 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
541 - . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
542 - . ' FROM (' . $this->index->order_digest_select_sql( '{id} = %d' ) . ') t'
543 - . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
544 - $order_id
545 - )
546 - );
547 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
548 - if ( false === $result ) {
549 - throw new RuntimeException( 'upsert stored order digest failed: ' . $wpdb->last_error );
550 - }
481 + $this->upsert_for( 'orders', $order_id );
551 482 }
552 483
553 484 /**
554 485 * Owe the product's or variation's digest; it is written once, on flush (see
555 - * $pending_digests). The queue type is 'post' for both: the upsert's SQL
486 + * $pending). Both share the registry's queue key: the upsert's SQL
556 487 * derives the stored object_type from the row, so nothing here needs to.
557 488 */
558 489 public function record_post_saved( int $post_id ): void {
559 - $this->defer( 'post', $post_id );
490 + $this->defer( self::pending_type( 'products' ), $post_id );
560 491 }
561 492
562 493 public function record_post_untrashed( int $post_id ): void {
563 494 $post_type = get_post_type( $post_id );
@@ -574,67 +505,49 @@
574 505 $post_type = get_post_type( $post_id );
575 506 if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
576 507 return;
577 508 }
578 - // A pending upsert for a record that is leaving must not be written after the fact.
579 - unset( self::$pending_digests[ self::pending_key( 'post', $post_id ) ] );
509 + $this->delete_for( 'products', $post_id, 'product_variation' === $post_type );
510 + }
511 +
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 );
516 + }
580 517 $this->observe(
581 - function () use ( $post_id, $post_type ): void {
582 - $this->delete_post_digest( $post_id, $post_type );
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 + }
583 538 }
584 539 );
585 540 }
586 541
587 542 /**
588 - * Remove a product/variation digest row after a hooked delete.
589 - *
590 - * A hooked delete removes the stored row so stored == current again.
591 - * Only a hook-BYPASSING delete leaves an orphan digest behind, which
592 - * the scan reports as a mismatch (stored side carries a row the
593 - * current side lacks) and the drill-down labels status=deleted.
594 - *
595 - * @param int $post_id The deleted post id.
596 - * @param string $post_type Its post type (product | product_variation).
597 - */
598 - private function delete_post_digest( int $post_id, string $post_type ): void {
599 - global $wpdb;
600 - $started = microtime( true );
601 - $deleted = $wpdb->delete(
602 - $this->table_name(),
603 - array(
604 - 'object_type' => 'product_variation' === $post_type ? 'variation' : 'product',
605 - 'object_id' => $post_id,
606 - ),
607 - array( '%s', '%d' )
608 - );
609 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
610 - if ( false === $deleted ) {
611 - throw new RuntimeException( 'delete stored digest failed: ' . $wpdb->last_error );
612 - }
613 - }
614 -
615 - /**
616 543 * One statement: the digest is computed in SQL from the raw row and
617 544 * upserted in the same statement — PHP never materializes the value.
618 545 * No-op for rows outside the live predicate (the delete hook owns those).
546 + * @deprecated Use record_post_saved().
619 547 */
620 548 public function upsert_digest( int $post_id ): void {
621 - global $wpdb;
622 - // Time from BEFORE the session setup so timing.digest_ms covers ALL digest hook work
623 - // (the raise runs inside the save hook — codex P3).
624 - $started = microtime( true );
625 - $this->index->raise_group_concat_max_len();
626 - $this->query_with_retry(
627 - $wpdb->prepare(
628 - 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
629 - . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
630 - . ' FROM (' . $this->index->row_digest_select_sql( 'p.ID = %d' ) . ') t'
631 - . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
632 - $post_id
633 - ),
634 - 'upsert stored digest failed: ',
635 - $started
636 - );
549 + $this->upsert_for( 'products', $post_id );
637 550 }
638 551
639 552 /**
640 553 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
@@ -639,11 +552,19 @@
639 552 /**
640 553 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
641 554 * compute and store one WordPress user's customer digest in a single
642 555 * INSERT…SELECT. Only the delete hook removes it.
556 + * @deprecated Use record_customer_saved().
643 557 */
644 558 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 {
645 564 global $wpdb;
565 + $digest = Collections::row( $collection )['digest'];
566 + $label = $digest['label'];
646 567 $started = microtime( true );
647 568 $this->index->raise_group_concat_max_len();
648 569 $this->query_with_retry(
649 570 $wpdb->prepare(
@@ -648,13 +569,13 @@
648 569 $this->query_with_retry(
649 570 $wpdb->prepare(
650 571 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
651 572 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
652 - . ' FROM (' . $this->index->customer_digest_select_sql( 'u.ID = %d' ) . ') t'
573 + . ' FROM (' . $this->index->{$digest['select']}( $digest['id_column'] . ' = %d' ) . ') t'
653 574 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
654 - $user_id
575 + $id
655 576 ),
656 - 'upsert stored customer digest failed: ',
577 + 'upsert stored ' . $label . 'digest failed: ',
657 578 $started
658 579 );
659 580 }
660 581