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