PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 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 All 169 releases
← All changes | includes/Sync/Sync_Journal.php +165 -5 1.10.4 → 1.10.17 View file →
@@ -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.