| @@ -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 | |