| @@ -27,8 +27,27 @@ | ||
| 27 | 27 | /** Per-request dedup of identical customer lifecycle events. */ |
| 28 | 28 | private array $recorded_this_request = array(); |
| 29 | 29 | |
| 30 | 30 | /** |
| 31 | + * The ONE order whose `hook:update` row is owed but not yet written. | |
| 32 | + * | |
| 33 | + * WooCommerce saves an order many times while building it: one Store API | |
| 34 | + * checkout fires `woocommerce_update_order` eleven times, and even a plain | |
| 35 | + * `$order->save()` on a CPT store fires it three times. Every firing used to | |
| 36 | + * append a row (an fsync, ~3 ms) after a three-query `wc_get_order()` — 12 | |
| 37 | + * rows and ~66 ms for ONE online order, measured 2026-09-03 on dev-next. | |
| 38 | + * A journal row is a change POINTER (ADR 0033), so one row per order per | |
| 39 | + * request carries the same information. | |
| 40 | + * | |
| 41 | + * Capacity one preserves ordering before a different order or other-origin | |
| 42 | + * row. Static so that guarantee holds across journal instances. The first | |
| 43 | + * instance needing the queue binds its writer, including after shutdown; | |
| 44 | + * all instances write the same table. Retaining the hook's order object | |
| 45 | + * avoids a refetch. See Request_Write_Queue for the queue mechanics. | |
| 46 | + */ | |
| 47 | + private static ?Request_Write_Queue $pending_updates = null; | |
| 48 | + | |
| 49 | + /** | |
| 31 | 50 | * Option-name prefix for the per-object-type lossy-prune watermarks. |
| 32 | 51 | * |
| 33 | 52 | * The watermark is scoped per object type for the same reason heads are |
| 34 | 53 | * stream-scoped: the streams share one AUTO_INCREMENT space, so a single |
| @@ -243,9 +262,16 @@ | ||
| 243 | 262 | add_action( 'remove_user_role', array( $this, 'record_customer_role_removed' ), 10, 2 ); |
| 244 | 263 | add_action( 'woocommerce_update_customer', array( $this, 'record_customer_updated' ), 10, 1 ); |
| 245 | 264 | add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 ); |
| 246 | 265 | add_action( 'woocommerce_new_order', array( $this, 'record_order_created' ), 10, 1 ); |
| 247 | - add_action( 'woocommerce_update_order', array( $this, 'record_order_updated' ), 10, 1 ); | |
| 266 | + // Two args: the data store passes ($order_id, $order). Keeping the object | |
| 267 | + // lets the coalesced flush read modified_gmt without a refetch. | |
| 268 | + add_action( 'woocommerce_update_order', array( $this, 'record_order_updated' ), 10, 2 ); | |
| 269 | + // Request boundary for the coalesced order update row. LAST on shutdown: | |
| 270 | + // WooCommerce saves the customer at 10 and the session at 20, and any | |
| 271 | + // save those trigger must still find the slot open. Zero accepted args: | |
| 272 | + // do_action( 'shutdown' ) passes an empty string otherwise. | |
| 273 | + add_action( 'shutdown', array( $this, 'flush_pending_order_updates_at_shutdown' ), PHP_INT_MAX, 0 ); | |
| 248 | 274 | add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 249 | 275 | add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 250 | 276 | add_action( 'woocommerce_untrash_order', array( $this, 'record_cot_order_untrashed' ), 10, 1 ); |
| 251 | 277 | add_action( 'woocommerce_pos_invalidate', array( $this, 'record_invalidation' ), 10, 2 ); |
| @@ -494,12 +520,69 @@ | ||
| 494 | 520 | public function record_order_created( int $order_id ): void { |
| 495 | 521 | $this->record_order_change( $order_id, 'hook:create', false ); |
| 496 | 522 | } |
| 497 | 523 | |
| 498 | - public function record_order_updated( int $order_id ): void { | |
| 499 | - $this->record_order_change( $order_id, 'hook:update', false ); | |
| 524 | + /** | |
| 525 | + * Mark an order's `hook:update` row as owed; the row lands on flush. | |
| 526 | + * | |
| 527 | + * See {@see $pending_updates} for why this is deferred. Direct callers | |
| 528 | + * that need an immediate row use {@see record_order_change()}. | |
| 529 | + * | |
| 530 | + * @param int $order_id Order id from the hook. | |
| 531 | + * @param \WC_Abstract_Order|mixed $order Order object from the hook (second | |
| 532 | + * argument of `woocommerce_update_order`), | |
| 533 | + * or anything else to fall back to a | |
| 534 | + * refetch at flush time. | |
| 535 | + */ | |
| 536 | + public function record_order_updated( int $order_id, $order = null ): void { | |
| 537 | + $order = $order instanceof \WC_Abstract_Order ? $order : null; | |
| 538 | + $this->queue()->owe( 'order', $order_id, $order ); | |
| 500 | 539 | } |
| 501 | 540 | |
| 541 | + /** | |
| 542 | + * Write the owed `hook:update` row, if any. | |
| 543 | + * | |
| 544 | + * Called from {@see record_order_change()} before any other-origin row and | |
| 545 | + * from the shutdown flush. Safe to call repeatedly: a flushed order is no | |
| 546 | + * longer pending. | |
| 547 | + */ | |
| 548 | + public function flush_pending_order_updates(): void { | |
| 549 | + if ( null !== self::$pending_updates ) { | |
| 550 | + self::$pending_updates->flush(); | |
| 551 | + } | |
| 552 | + } | |
| 553 | + | |
| 554 | + /** | |
| 555 | + * The `shutdown` callback: flush, then write every later update immediately. | |
| 556 | + */ | |
| 557 | + public function flush_pending_order_updates_at_shutdown(): void { | |
| 558 | + $this->queue()->flush_at_shutdown(); | |
| 559 | + } | |
| 560 | + | |
| 561 | + /** | |
| 562 | + * Discard per-request coalescing state. Tests only: the PHPUnit process | |
| 563 | + * never reaches `shutdown`, so the static queue would leak between | |
| 564 | + * test cases otherwise. | |
| 565 | + * | |
| 566 | + * @internal | |
| 567 | + */ | |
| 568 | + public static function reset_request_state(): void { | |
| 569 | + self::$pending_updates = null; | |
| 570 | + } | |
| 571 | + | |
| 572 | + /** Bind the first journal instance to the request's shared queue. */ | |
| 573 | + private function queue(): Request_Write_Queue { | |
| 574 | + if ( null === self::$pending_updates ) { | |
| 575 | + self::$pending_updates = new Request_Write_Queue( | |
| 576 | + 1, | |
| 577 | + function ( $type, $id, $order ): void { | |
| 578 | + $this->record_order_change( $id, 'hook:update', false, $order ); | |
| 579 | + } | |
| 580 | + ); | |
| 581 | + } | |
| 582 | + return self::$pending_updates; | |
| 583 | + } | |
| 584 | + | |
| 502 | 585 | public function record_order_deleted( int $order_id ): void { |
| 503 | 586 | $this->record_order_change( $order_id, 'hook:delete', true ); |
| 504 | 587 | } |
| 505 | 588 | |
| @@ -539,11 +622,36 @@ | ||
| 539 | 622 | }; |
| 540 | 623 | add_action( 'woocommerce_order_status_changed', $handler, 10, 2 ); |
| 541 | 624 | } |
| 542 | 625 | |
| 543 | - public function record_order_change( int $order_id, string $origin, bool $deleted ): bool { | |
| 626 | + /** | |
| 627 | + * Append one order row immediately. | |
| 628 | + * | |
| 629 | + * @param int $order_id Order id. | |
| 630 | + * @param string $origin Row origin (`hook:create`, `hook:update`, …). | |
| 631 | + * @param bool $deleted Whether the row is a tombstone. | |
| 632 | + * @param \WC_Abstract_Order|mixed $order The order object when the caller already holds it; | |
| 633 | + * anything else triggers a refetch. | |
| 634 | + * | |
| 635 | + * @return bool Whether the insert succeeded. | |
| 636 | + */ | |
| 637 | + public function record_order_change( int $order_id, string $origin, bool $deleted, $order = null ): bool { | |
| 544 | 638 | global $wpdb; |
| 545 | - $order = wc_get_order( $order_id ); | |
| 639 | + if ( 'hook:update' !== $origin ) { | |
| 640 | + if ( 'hook:create' === $origin && $this->queue()->owes( 'order', $order_id ) ) { | |
| 641 | + // The Store API saves a checkout-draft several times BEFORE | |
| 642 | + // `woocommerce_new_order` fires. Both rows would point at the same | |
| 643 | + // live record, so the create row makes the owed update row redundant. | |
| 644 | + $this->queue()->drop( 'order', $order_id ); | |
| 645 | + } else { | |
| 646 | + // Land the owed update row FIRST so the stream never reads as | |
| 647 | + // delete-then-update (a replay would resurrect a trashed order). | |
| 648 | + $this->flush_pending_order_updates(); | |
| 649 | + } | |
| 650 | + } | |
| 651 | + if ( ! $order instanceof \WC_Abstract_Order ) { | |
| 652 | + $order = wc_get_order( $order_id ); | |
| 653 | + } | |
| 546 | 654 | $modified_date = $order ? $order->get_date_modified() : null; |
| 547 | 655 | $modified = $modified_date ? gmdate( 'Y-m-d H:i:s', $modified_date->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); |
| 548 | 656 | // Order revisions are computed at pull time from the served payload (ADR 0033, |
| 549 | 657 | // #1746) — an order journal row is a change pointer, not a content stamp. |