PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 +250 -128 1.10.31.10.18 View file →
@@ -17,16 +17,20 @@
17 17
18 18 /**
19 19 * Hash-backed range-checksum support: stored per-record content digests.
20 20 *
21 - * STORES a digest of each product/variation's raw DB row at hook time (the
22 - * same save/delete hooks class-change-log.php uses), so the integrity scan
21 + * STORES a digest of each product/variation's raw DB row, marked dirty by the
22 + * same save/delete hooks class-change-log.php uses and written at the request
23 + * boundary (see $pending), so the integrity scan
23 24 * can compare — entirely in SQL — the aggregate of CURRENT raw-row digests
24 25 * against the aggregate of STORED digests per id-range bucket. If hooks
25 26 * fired for every write, stored == current (and sequence-log already
26 27 * reported the change); a bucket mismatch therefore means exactly "content
27 28 * changed without hooks firing" — the sql-bypass signature — at GROUP BY
28 - * prices instead of revision-hash's full-hydration prices.
29 + * prices instead of revision-hash's full-hydration prices. Because the write
30 + * lands at the boundary, a hook-less write later in the SAME request is
31 + * absorbed into that request's digest; the scan catches bypasses between
32 + * requests, which is where they happen (a direct SQL job, an importer).
29 33 *
30 34 * The digest basis is deliberately the RAW DB ROW, NOT the filtered REST
31 35 * payload: this signal is detection-only (discovery of WHERE drift
32 36 * happened, ADR 0003 "discovery, never values"); hydration of anything the
@@ -76,8 +80,39 @@
76 80 * computed by ONE expression — the invariant the whole scan rests on.
77 81 */
78 82 private Digest_Index $index;
79 83
84 + /**
85 + * Digests owed but not yet written, keyed by blog, type and id.
86 + *
87 + * A stored digest is a pure function of the settled record, so only the
88 + * LAST upsert in a request carries information — yet one Store API checkout
89 + * ran the order INSERT…SELECT eleven times (35 ms) and, with account
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`.
98 + *
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.
103 + */
104 + private static ?Request_Write_Queue $pending = null;
105 +
106 + /**
107 + * The most distinct records held; the next distinct record flushes them first.
108 + * Sized for the
109 + * realistic per-request maximum (a checkout touches an order and a customer;
110 + * a REST batch a few dozen records) while keeping a WP-CLI import's deferred
111 + * SQL and memory bounded.
112 + */
113 + public const PENDING_DIGEST_FLUSH_THRESHOLD = 50;
114 +
80 115 public function __construct( ?Digest_Index $index = null ) {
81 116 $this->index = $index ?? new Digest_Index();
82 117 }
83 118
@@ -160,8 +195,13 @@
160 195 // never fires for COT orders — without the HPOS twin hook a restored
161 196 // order's digest is never recreated and integrity scans treat it as
162 197 // deleted forever.
163 198 add_action( 'woocommerce_untrash_order', array( $this, 'record_order_untrashed' ), 10, 1 );
199 + // Request boundary for the coalesced digest upserts (see
200 + // $pending). LAST on shutdown: WooCommerce saves the customer at
201 + // 10 and the session at 20. Zero accepted args: do_action( 'shutdown' )
202 + // passes an empty string otherwise.
203 + add_action( 'shutdown', array( __CLASS__, 'flush_pending_digests_at_shutdown' ), PHP_INT_MAX, 0 );
164 204 }
165 205
166 206 /**
167 207 * Recreate a COT order's digest once its restore completes.
@@ -242,8 +282,23 @@
242 282 return $registered;
243 283 }
244 284
245 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 + /**
246 301 * Attach each served record's stored 64-bit digest as a top-level `_rxdb_digest`
247 302 * string, so the client seeds its existence-reconcile manifest (ADR 0014 Leg 3)
248 303 * as records flow through the NORMAL pull — no separate fetch. The client reads
249 304 * it into the sidecar manifest; it is NOT persisted into the document. A record
@@ -298,22 +353,93 @@
298 353 /**
299 354 * Customer digest maintenance (ADR 0015, Leg-3 phase 7) — every WordPress
300 355 * user is a POS customer, so saves and role changes always upsert.
301 356 */
357 + /** Owe the customer's digest; it is written once, on flush (see $pending). */
302 358 public function record_customer_saved( int $user_id ): void {
359 + $this->defer( self::pending_type( 'customers' ), $user_id );
360 + }
361 +
362 + /**
363 + * Queue one digest upsert, or write it now if the boundary has passed.
364 + *
365 + * @param string $type Registry digest object-types key.
366 + * @param int $id Record id.
367 + */
368 + 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 + );
382 + }
383 + return self::$pending;
384 + }
385 +
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'] );
389 + }
390 +
391 + /** One queued upsert, under the observer's fail-open posture. */
392 + private function upsert_pending( string $type, int $id ): void {
303 393 $this->observe(
304 - function () use ( $user_id ): void {
305 - $this->upsert_customer_digest( $user_id );
394 + 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 + }
400 + }
401 + Logger::warning( 'WCPOS sync: no digest collection matches queued type: ' . $type );
306 402 }
307 403 );
308 404 }
309 405
406 + /**
407 + * Write every owed digest.
408 + *
409 + * 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
411 + * through the instance that first queued (so an injected Digest_Index is
412 + * honoured) and under the blog each entry was recorded on. Each upsert keeps
413 + * the observer's fail-open posture: a failure is logged and the scan
414 + * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
415 + */
416 + public static function flush_pending_digests(): void {
417 + if ( null !== self::$pending ) {
418 + self::$pending->flush();
419 + }
420 + }
421 +
422 + /**
423 + * The `shutdown` callback: flush, then write every later save immediately.
424 + */
425 + public static function flush_pending_digests_at_shutdown(): void {
426 + self::queue()->flush_at_shutdown();
427 + }
428 +
429 + /**
430 + * Discard per-request coalescing state. Tests only: the PHPUnit process
431 + * never reaches `shutdown`, so the static queue would
432 + * leak between test cases otherwise.
433 + *
434 + * @internal
435 + */
436 + public static function reset_request_state(): void {
437 + self::$pending = null;
438 + }
439 +
310 440 public function record_customer_deleted( int $user_id ): void {
311 - $this->observe(
312 - function () use ( $user_id ): void {
313 - $this->delete_customer_digest( $user_id );
314 - }
315 - );
441 + $this->delete_for( 'customers', $user_id );
316 442 }
317 443
318 444 /**
319 445 * Observation hooks must never break the host write that fired them: a
@@ -331,93 +457,49 @@
331 457 Logger::error( 'Sync digest observer failed (sync will self-heal via scan/rebuild): ' . $e->getMessage() );
332 458 }
333 459 }
334 460
335 - private function delete_customer_digest( int $user_id ): void {
336 - global $wpdb;
337 - $deleted = $wpdb->delete(
338 - $this->table_name(),
339 - array(
340 - 'object_type' => 'customer',
341 - 'object_id' => $user_id,
342 - ),
343 - array( '%s', '%d' )
344 - );
345 - if ( false === $deleted ) {
346 - throw new RuntimeException( 'delete stored customer digest failed: ' . $wpdb->last_error );
347 - }
348 - }
349 -
350 461 /**
351 462 * Order digest maintenance (ADR 0015, Leg-3 phase 7). The WC order hooks are storage-agnostic (fire
352 463 * under HPOS AND CPT); the digest SQL's `type='shop_order'` filter makes the upsert a no-op for any
353 464 * non-order, so no type re-check is needed here.
354 465 */
466 + /** Owe the order's digest; it is written once, on flush (see $pending). */
355 467 public function record_order_saved( int $order_id ): void {
356 - $this->observe(
357 - function () use ( $order_id ): void {
358 - $this->upsert_order_digest( $order_id );
359 - }
360 - );
468 + $this->defer( self::pending_type( 'orders' ), $order_id );
361 469 }
362 470
363 471 public function record_order_deleted( int $order_id ): void {
364 - $this->observe(
365 - function () use ( $order_id ): void {
366 - $this->delete_order_digest( $order_id );
367 - }
368 - );
472 + $this->delete_for( 'orders', $order_id );
369 473 }
370 474
371 - private function delete_order_digest( int $order_id ): void {
372 - global $wpdb;
373 - $deleted = $wpdb->delete(
374 - $this->table_name(),
375 - array(
376 - 'object_type' => 'order',
377 - 'object_id' => $order_id,
378 - ),
379 - array( '%s', '%d' )
380 - );
381 - if ( false === $deleted ) {
382 - throw new RuntimeException( 'delete stored order digest failed: ' . $wpdb->last_error );
383 - }
384 - }
385 -
386 - /** 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 + */
387 480 public function upsert_order_digest( int $order_id ): void {
388 - global $wpdb;
389 - $started = microtime( true );
390 - $this->index->raise_group_concat_max_len();
391 - $result = $wpdb->query(
392 - $wpdb->prepare(
393 - 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
394 - . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
395 - . ' FROM (' . $this->index->order_digest_select_sql( '{id} = %d' ) . ') t'
396 - . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
397 - $order_id
398 - )
399 - );
400 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
401 - if ( false === $result ) {
402 - throw new RuntimeException( 'upsert stored order digest failed: ' . $wpdb->last_error );
403 - }
481 + $this->upsert_for( 'orders', $order_id );
404 482 }
405 483
484 + /**
485 + * 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
487 + * derives the stored object_type from the row, so nothing here needs to.
488 + */
406 489 public function record_post_saved( int $post_id ): void {
407 - $this->observe(
408 - function () use ( $post_id ): void {
409 - $this->upsert_digest( $post_id );
410 - }
411 - );
490 + $this->defer( self::pending_type( 'products' ), $post_id );
412 491 }
413 492
414 493 public function record_post_untrashed( int $post_id ): void {
415 - if ( 'shop_order' === get_post_type( $post_id ) ) {
494 + $post_type = get_post_type( $post_id );
495 + if ( 'shop_order' === $post_type ) {
416 496 $this->record_order_saved( $post_id );
417 497 return;
418 498 }
419 - $this->record_post_saved( $post_id );
499 + if ( in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
500 + $this->record_post_saved( $post_id );
501 + }
420 502 }
421 503
422 504 public function record_post_deleted( int $post_id ): void {
423 505 $post_type = get_post_type( $post_id );
@@ -423,67 +505,49 @@
423 505 $post_type = get_post_type( $post_id );
424 506 if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
425 507 return;
426 508 }
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 + }
427 517 $this->observe(
428 - function () use ( $post_id, $post_type ): void {
429 - $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 + }
430 538 }
431 539 );
432 540 }
433 541
434 542 /**
435 - * Remove a product/variation digest row after a hooked delete.
436 - *
437 - * A hooked delete removes the stored row so stored == current again.
438 - * Only a hook-BYPASSING delete leaves an orphan digest behind, which
439 - * the scan reports as a mismatch (stored side carries a row the
440 - * current side lacks) and the drill-down labels status=deleted.
441 - *
442 - * @param int $post_id The deleted post id.
443 - * @param string $post_type Its post type (product | product_variation).
444 - */
445 - private function delete_post_digest( int $post_id, string $post_type ): void {
446 - global $wpdb;
447 - $started = microtime( true );
448 - $deleted = $wpdb->delete(
449 - $this->table_name(),
450 - array(
451 - 'object_type' => 'product_variation' === $post_type ? 'variation' : 'product',
452 - 'object_id' => $post_id,
453 - ),
454 - array( '%s', '%d' )
455 - );
456 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
457 - if ( false === $deleted ) {
458 - throw new RuntimeException( 'delete stored digest failed: ' . $wpdb->last_error );
459 - }
460 - }
461 -
462 - /**
463 - * One round trip: the digest is computed in SQL from the raw row and
543 + * One statement: the digest is computed in SQL from the raw row and
464 544 * upserted in the same statement — PHP never materializes the value.
465 545 * No-op for rows outside the live predicate (the delete hook owns those).
546 + * @deprecated Use record_post_saved().
466 547 */
467 548 public function upsert_digest( int $post_id ): void {
468 - global $wpdb;
469 - // Time from BEFORE the session setup so timing.digest_ms covers ALL digest hook work
470 - // (the raise runs inside the save hook — codex P3).
471 - $started = microtime( true );
472 - $this->index->raise_group_concat_max_len();
473 - $result = $wpdb->query(
474 - $wpdb->prepare(
475 - 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
476 - . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
477 - . ' FROM (' . $this->index->row_digest_select_sql( 'p.ID = %d' ) . ') t'
478 - . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
479 - $post_id
480 - )
481 - );
482 - self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
483 - if ( false === $result ) {
484 - throw new RuntimeException( 'upsert stored digest failed: ' . $wpdb->last_error );
485 - }
549 + $this->upsert_for( 'products', $post_id );
486 550 }
487 551
488 552 /**
489 553 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
@@ -488,26 +552,84 @@
488 552 /**
489 553 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
490 554 * compute and store one WordPress user's customer digest in a single
491 555 * INSERT…SELECT. Only the delete hook removes it.
556 + * @deprecated Use record_customer_saved().
492 557 */
493 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 {
494 564 global $wpdb;
565 + $digest = Collections::row( $collection )['digest'];
566 + $label = $digest['label'];
495 567 $started = microtime( true );
496 568 $this->index->raise_group_concat_max_len();
497 - $result = $wpdb->query(
569 + $this->query_with_retry(
498 570 $wpdb->prepare(
499 571 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
500 572 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
501 - . ' FROM (' . $this->index->customer_digest_select_sql( 'u.ID = %d' ) . ') t'
573 + . ' FROM (' . $this->index->{$digest['select']}( $digest['id_column'] . ' = %d' ) . ') t'
502 574 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
503 - $user_id
504 - )
575 + $id
576 + ),
577 + 'upsert stored ' . $label . 'digest failed: ',
578 + $started
505 579 );
580 + }
581 +
582 + /**
583 + * MySQL/MariaDB error numbers a second attempt can clear: 1020 ER_CHECKREAD
584 + * ("Record has changed since last read"), 1205 ER_LOCK_WAIT_TIMEOUT, 1213
585 + * ER_LOCK_DEADLOCK. Two requests upserting the same digest row race on
586 + * the `INSERT … ON DUPLICATE KEY UPDATE`; the retry reads the updated row.
587 + */
588 + private const TRANSIENT_CONTENTION_ERRNOS = array( 1020, 1205, 1213 );
589 +
590 + /**
591 + * Message fallback for the same three errors, used only when the driver's
592 + * error number is unavailable (a wpdb without a live mysqli handle).
593 + */
594 + private const TRANSIENT_CONTENTION_MESSAGES = array(
595 + 'Record has changed since last read',
596 + 'Lock wait timeout',
597 + 'Deadlock found',
598 + );
599 +
600 + /** Retry a contended upsert once, including both attempts in the hook timing. */
601 + private function query_with_retry( string $sql, string $error_message, float $started ): void {
602 + global $wpdb;
603 + $result = $wpdb->query( $sql );
604 + if ( false === $result && $this->is_transient_contention( $wpdb ) ) {
605 + $result = $wpdb->query( $sql );
606 + }
506 607 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
507 608 if ( false === $result ) {
508 - throw new RuntimeException( 'upsert stored customer digest failed: ' . $wpdb->last_error );
609 + throw new RuntimeException( $error_message . $wpdb->last_error );
509 610 }
611 + }
612 +
613 + /**
614 + * The error number is authoritative: server messages are localised
615 + * (`lc_messages`), so the English text is only a fallback for a handle-less
616 + * wpdb. `$wpdb->dbh` is reachable through wpdb's magic getter.
617 + */
618 + private function is_transient_contention( \wpdb $wpdb ): bool {
619 + $dbh = $wpdb->__get( 'dbh' );
620 + if ( $dbh instanceof \mysqli ) {
621 + $errno = mysqli_errno( $dbh ); // phpcs:ignore WordPress.DB.RestrictedFunctions -- reads the driver's last error number; no query is issued.
622 + if ( 0 !== $errno ) {
623 + return in_array( $errno, self::TRANSIENT_CONTENTION_ERRNOS, true );
624 + }
625 + }
626 + foreach ( self::TRANSIENT_CONTENTION_MESSAGES as $message ) {
627 + if ( false !== strpos( $wpdb->last_error, $message ) ) {
628 + return true;
629 + }
630 + }
631 + return false;
510 632 }
511 633
512 634 /**
513 635 * Backfill/repair: prune orphans, then digest every live row in one