| @@ -27,8 +27,40 @@ | ||
| 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 | + * Single slot, not a map: a save for a DIFFERENT order flushes the pending | |
| 42 | + * one first (so a bulk loop never holds rows until process end), which | |
| 43 | + * means at most one order is ever pending. Static, not per instance: the | |
| 44 | + * "update row lands before any other-origin row" guarantee must hold for | |
| 45 | + * whichever `Sync_Journal` instance writes the other row. The slot keeps the | |
| 46 | + * blog id so a multisite `switch_to_blog()` between save and flush still | |
| 47 | + * writes to the originating site's table, and the order object the hook | |
| 48 | + * handed us so the flush never refetches. | |
| 49 | + * | |
| 50 | + * Rows land on {@see flush_pending_order_updates()}: at `shutdown` (last, | |
| 51 | + * after WooCommerce's own shutdown saves), before any other-origin row, or | |
| 52 | + * when a different order is saved. Once the shutdown flush has run, later | |
| 53 | + * updates write immediately. | |
| 54 | + * | |
| 55 | + * @var array{blog: int, id: int, order: \WC_Abstract_Order|null}|null | |
| 56 | + */ | |
| 57 | + private static ?array $pending_order_update = null; | |
| 58 | + | |
| 59 | + /** Set by the shutdown flush; afterwards updates are written immediately. */ | |
| 60 | + private static bool $shutdown_flushed = false; | |
| 61 | + | |
| 62 | + /** | |
| 31 | 63 | * Option-name prefix for the per-object-type lossy-prune watermarks. |
| 32 | 64 | * |
| 33 | 65 | * The watermark is scoped per object type for the same reason heads are |
| 34 | 66 | * stream-scoped: the streams share one AUTO_INCREMENT space, so a single |
| @@ -243,9 +275,16 @@ | ||
| 243 | 275 | add_action( 'remove_user_role', array( $this, 'record_customer_role_removed' ), 10, 2 ); |
| 244 | 276 | add_action( 'woocommerce_update_customer', array( $this, 'record_customer_updated' ), 10, 1 ); |
| 245 | 277 | add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 ); |
| 246 | 278 | 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 ); | |
| 279 | + // Two args: the data store passes ($order_id, $order). Keeping the object | |
| 280 | + // lets the coalesced flush read modified_gmt without a refetch. | |
| 281 | + add_action( 'woocommerce_update_order', array( $this, 'record_order_updated' ), 10, 2 ); | |
| 282 | + // Request boundary for the coalesced order update row. LAST on shutdown: | |
| 283 | + // WooCommerce saves the customer at 10 and the session at 20, and any | |
| 284 | + // save those trigger must still find the slot open. Zero accepted args: | |
| 285 | + // do_action( 'shutdown' ) passes an empty string otherwise. | |
| 286 | + add_action( 'shutdown', array( $this, 'flush_pending_order_updates_at_shutdown' ), PHP_INT_MAX, 0 ); | |
| 248 | 287 | add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 249 | 288 | add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 250 | 289 | add_action( 'woocommerce_untrash_order', array( $this, 'record_cot_order_untrashed' ), 10, 1 ); |
| 251 | 290 | add_action( 'woocommerce_pos_invalidate', array( $this, 'record_invalidation' ), 10, 2 ); |
| @@ -494,12 +533,107 @@ | ||
| 494 | 533 | public function record_order_created( int $order_id ): void { |
| 495 | 534 | $this->record_order_change( $order_id, 'hook:create', false ); |
| 496 | 535 | } |
| 497 | 536 | |
| 498 | - public function record_order_updated( int $order_id ): void { | |
| 499 | - $this->record_order_change( $order_id, 'hook:update', false ); | |
| 537 | + /** | |
| 538 | + * Mark an order's `hook:update` row as owed; the row lands on flush. | |
| 539 | + * | |
| 540 | + * See {@see $pending_order_updates} for why this is deferred. Direct callers | |
| 541 | + * that need an immediate row use {@see record_order_change()}. | |
| 542 | + * | |
| 543 | + * @param int $order_id Order id from the hook. | |
| 544 | + * @param \WC_Abstract_Order|mixed $order Order object from the hook (second | |
| 545 | + * argument of `woocommerce_update_order`), | |
| 546 | + * or anything else to fall back to a | |
| 547 | + * refetch at flush time. | |
| 548 | + */ | |
| 549 | + public function record_order_updated( int $order_id, $order = null ): void { | |
| 550 | + $order = $order instanceof \WC_Abstract_Order ? $order : null; | |
| 551 | + if ( self::$shutdown_flushed ) { | |
| 552 | + // The request boundary has passed (a save triggered by another | |
| 553 | + // shutdown handler): nothing will flush again, so write now. | |
| 554 | + $this->record_order_change( $order_id, 'hook:update', false, $order ); | |
| 555 | + return; | |
| 556 | + } | |
| 557 | + $blog = get_current_blog_id(); | |
| 558 | + $slot = self::$pending_order_update; | |
| 559 | + if ( null !== $slot && ( $slot['id'] !== $order_id || $slot['blog'] !== $blog ) ) { | |
| 560 | + // A different order began: land what is owed so a bulk loop (WP-CLI | |
| 561 | + // import, Action Scheduler runner) never holds rows until process end. | |
| 562 | + $this->flush_pending_order_updates(); | |
| 563 | + $slot = null; | |
| 564 | + } | |
| 565 | + self::$pending_order_update = array( | |
| 566 | + 'blog' => $blog, | |
| 567 | + 'id' => $order_id, | |
| 568 | + 'order' => $order ?? ( $slot['order'] ?? null ), | |
| 569 | + ); | |
| 500 | 570 | } |
| 501 | 571 | |
| 572 | + /** | |
| 573 | + * Write the owed `hook:update` row, if any. | |
| 574 | + * | |
| 575 | + * Called from {@see record_order_change()} before any other-origin row and | |
| 576 | + * from the shutdown flush. Safe to call repeatedly: a flushed order is no | |
| 577 | + * longer pending. | |
| 578 | + */ | |
| 579 | + public function flush_pending_order_updates(): void { | |
| 580 | + $slot = self::$pending_order_update; | |
| 581 | + if ( null === $slot ) { | |
| 582 | + return; | |
| 583 | + } | |
| 584 | + self::$pending_order_update = null; | |
| 585 | + self::in_blog( | |
| 586 | + $slot['blog'], | |
| 587 | + function () use ( $slot ): void { | |
| 588 | + $this->record_order_change( $slot['id'], 'hook:update', false, $slot['order'] ); | |
| 589 | + } | |
| 590 | + ); | |
| 591 | + } | |
| 592 | + | |
| 593 | + /** | |
| 594 | + * The `shutdown` callback: flush, then write every later update immediately. | |
| 595 | + */ | |
| 596 | + public function flush_pending_order_updates_at_shutdown(): void { | |
| 597 | + self::$shutdown_flushed = true; | |
| 598 | + $this->flush_pending_order_updates(); | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 602 | + * Discard per-request coalescing state. Tests only: the PHPUnit process | |
| 603 | + * never reaches `shutdown`, so the static slot and flag would leak between | |
| 604 | + * test cases otherwise. | |
| 605 | + * | |
| 606 | + * @internal | |
| 607 | + */ | |
| 608 | + public static function reset_request_state(): void { | |
| 609 | + self::$pending_order_update = null; | |
| 610 | + self::$shutdown_flushed = false; | |
| 611 | + } | |
| 612 | + | |
| 613 | + /** | |
| 614 | + * Run a write under the blog it was recorded on. | |
| 615 | + * | |
| 616 | + * The journal table is blog-scoped, so a deferred write must not follow a | |
| 617 | + * `switch_to_blog()` that happened between the save and the flush. | |
| 618 | + * | |
| 619 | + * @param int $blog_id Blog the write belongs to. | |
| 620 | + * @param callable $write The write. | |
| 621 | + */ | |
| 622 | + private static function in_blog( int $blog_id, callable $write ): void { | |
| 623 | + $switch = is_multisite() && get_current_blog_id() !== $blog_id; | |
| 624 | + if ( $switch ) { | |
| 625 | + switch_to_blog( $blog_id ); | |
| 626 | + } | |
| 627 | + try { | |
| 628 | + $write(); | |
| 629 | + } finally { | |
| 630 | + if ( $switch ) { | |
| 631 | + restore_current_blog(); | |
| 632 | + } | |
| 633 | + } | |
| 634 | + } | |
| 635 | + | |
| 502 | 636 | public function record_order_deleted( int $order_id ): void { |
| 503 | 637 | $this->record_order_change( $order_id, 'hook:delete', true ); |
| 504 | 638 | } |
| 505 | 639 | |
| @@ -539,11 +673,37 @@ | ||
| 539 | 673 | }; |
| 540 | 674 | add_action( 'woocommerce_order_status_changed', $handler, 10, 2 ); |
| 541 | 675 | } |
| 542 | 676 | |
| 543 | - public function record_order_change( int $order_id, string $origin, bool $deleted ): bool { | |
| 677 | + /** | |
| 678 | + * Append one order row immediately. | |
| 679 | + * | |
| 680 | + * @param int $order_id Order id. | |
| 681 | + * @param string $origin Row origin (`hook:create`, `hook:update`, …). | |
| 682 | + * @param bool $deleted Whether the row is a tombstone. | |
| 683 | + * @param \WC_Abstract_Order|mixed $order The order object when the caller already holds it; | |
| 684 | + * anything else triggers a refetch. | |
| 685 | + * | |
| 686 | + * @return bool Whether the insert succeeded. | |
| 687 | + */ | |
| 688 | + public function record_order_change( int $order_id, string $origin, bool $deleted, $order = null ): bool { | |
| 544 | 689 | global $wpdb; |
| 545 | - $order = wc_get_order( $order_id ); | |
| 690 | + if ( 'hook:update' !== $origin ) { | |
| 691 | + $slot = self::$pending_order_update; | |
| 692 | + if ( 'hook:create' === $origin && null !== $slot && $order_id === $slot['id'] && get_current_blog_id() === $slot['blog'] ) { | |
| 693 | + // The Store API saves a checkout-draft several times BEFORE | |
| 694 | + // `woocommerce_new_order` fires. Both rows would point at the same | |
| 695 | + // live record, so the create row makes the owed update row redundant. | |
| 696 | + self::$pending_order_update = null; | |
| 697 | + } else { | |
| 698 | + // Land the owed update row FIRST so the stream never reads as | |
| 699 | + // delete-then-update (a replay would resurrect a trashed order). | |
| 700 | + $this->flush_pending_order_updates(); | |
| 701 | + } | |
| 702 | + } | |
| 703 | + if ( ! $order instanceof \WC_Abstract_Order ) { | |
| 704 | + $order = wc_get_order( $order_id ); | |
| 705 | + } | |
| 546 | 706 | $modified_date = $order ? $order->get_date_modified() : null; |
| 547 | 707 | $modified = $modified_date ? gmdate( 'Y-m-d H:i:s', $modified_date->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); |
| 548 | 708 | // Order revisions are computed at pull time from the served payload (ADR 0033, |
| 549 | 709 | // #1746) — an order journal row is a change pointer, not a content stamp. |