PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.20
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.20
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 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 All 164 releases
← All changes | includes/Sync/Sync_Journal.php +198 -19 1.10.2 → 1.10.20 View file →
@@ -11,9 +11,9 @@
11 11 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries use internal table names and generated SQL fragments.
12 12 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database failures are passed to exceptions, not rendered.
13 13
14 14 use Automattic\WooCommerce\Utilities\OrderUtil;
15 -use WP_REST_Request;
15 +use WCPOS\WooCommercePOS\Logger;
16 16
17 17 final class Sync_Journal {
18 18 /** Persisted order backfill cursor. */
19 19 public const BACKFILL_OPTION = 'woocommerce_pos_sync_index_backfill';
@@ -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
@@ -44,8 +63,15 @@
44 63 global $wpdb;
45 64 return $wpdb->prefix . Health::SYNC_JOURNAL_TABLE;
46 65 }
47 66
67 + /**
68 + * The `revision` column is a per-lane union: a `date_modified` stamp for
69 + * catalogue/customer rows, `''` for live order rows (order revisions are
70 + * computed at pull time — ADR 0033), `'deleted'` for order tombstones, and
71 + * legacy pre-#1746 order rows may still carry stored `sha256:` hashes,
72 + * which the pull planner's stored-wins branch serves until they age out.
73 + */
48 74 public function schema_sql( string $table_name, string $charset_collate = '' ): string {
49 75 return "CREATE TABLE {$table_name} (\n"
50 76 . " sequence BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n"
51 77 . " object_type VARCHAR(20) NOT NULL,\n"
@@ -236,14 +262,90 @@
236 262 add_action( 'remove_user_role', array( $this, 'record_customer_role_removed' ), 10, 2 );
237 263 add_action( 'woocommerce_update_customer', array( $this, 'record_customer_updated' ), 10, 1 );
238 264 add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 );
239 265 add_action( 'woocommerce_new_order', array( $this, 'record_order_created' ), 10, 1 );
240 - 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 );
241 274 add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 );
242 275 add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 );
243 276 add_action( 'woocommerce_untrash_order', array( $this, 'record_cot_order_untrashed' ), 10, 1 );
277 + add_action( 'woocommerce_pos_invalidate', array( $this, 'record_invalidation' ), 10, 2 );
244 278 }
245 279
280 + /**
281 + * Record an out-of-band change announced by an extension.
282 + *
283 + * Plugins fire `woocommerce_pos_invalidate` when they change a record's
284 + * SERVED representation in a way no save hook announces — a filter-only
285 + * output change (a pricing filter, an added payload field). The journal
286 + * appends a pointer row; clients hydrate pointer rows by sequence, so the
287 + * re-served payload carries the plugin's change. Formula fingerprints
288 + * (#1742) will eventually make representation changes directly detectable;
289 + * until then this action is the documented relief valve.
290 + *
291 + * `$object_type` is the registry's SINGULAR journal name: `product`,
292 + * `variation`, `customer`, `order`, `tax_rate`, and the other journalled
293 + * catalogue types. A plural (`products`) or unknown type is logged and
294 + * ignored. Rows land with origin `invalidate` on every type.
295 + *
296 + * @since 1.10.3
297 + *
298 + * @param string $object_type Canonical (singular) journal object type.
299 + * @param int $object_id Changed object ID.
300 + */
301 + public function record_invalidation( $object_type = '', $object_id = 0 ): void {
302 + // Loose signature on purpose: a public action handler whose posture is
303 + // log-and-ignore — a one-arg or wrong-typed do_action() must not fatal
304 + // the calling plugin's request.
305 + $object_type = is_scalar( $object_type ) ? (string) $object_type : '';
306 + $object_id = is_scalar( $object_id ) ? (int) $object_id : 0;
307 + $collection = Collections::by_object_type( $object_type );
308 + if ( $object_id <= 0 || null === $collection || ! isset( $collection['journal'] ) ) {
309 + Logger::log( sprintf( 'WCPOS sync: ignored invalidation for object_type "%s" (id %d)', $object_type, $object_id ) );
310 + return;
311 + }
312 +
313 + if ( 'order' === $object_type ) {
314 + $this->record_order_change( $object_id, 'invalidate', false );
315 + return;
316 + }
317 + $loader = (string) ( $collection['identity']['loader'] ?? '' );
318 + if ( 'product' === $loader ) {
319 + $object = function_exists( 'wc_get_product' ) ? wc_get_product( $object_id ) : null;
320 + $this->record( $object_type, $object_id, false, self::object_revision( $object ), 'invalidate' );
321 + if ( 'variation' === $object_type ) {
322 + // Native variation paths always pair the parent row — the parent
323 + // document carries the variable price range — so an invalidation
324 + // must too, or the relief valve half-works. Recorded inline (not via
325 + // record_variation_parent) so the paired row keeps the 'invalidate'
326 + // origin the contract above promises for every row this action lands.
327 + $parent_id = function_exists( 'wp_get_post_parent_id' ) ? (int) wp_get_post_parent_id( $object_id ) : 0;
328 + if ( $parent_id > 0 ) {
329 + $parent = function_exists( 'wc_get_product' ) ? wc_get_product( $parent_id ) : null;
330 + $this->record( 'product', $parent_id, false, self::object_revision( $parent ), 'invalidate' );
331 + }
332 + }
333 + return;
334 + }
335 + if ( 'customer' === $loader ) {
336 + try {
337 + $customer = class_exists( '\\WC_Customer' ) ? new \WC_Customer( $object_id ) : null;
338 + } catch ( \Exception $e ) {
339 + Logger::log( sprintf( 'WCPOS sync: ignored invalidation for missing customer %d', $object_id ) );
340 + return;
341 + }
342 + $this->record( 'customer', $object_id, false, self::object_revision( $customer ), 'invalidate', true, 'invalidate' );
343 + return;
344 + }
345 + $this->record( $object_type, $object_id, false, '', 'invalidate' );
346 + }
347 +
246 348 public function record_product_created( int $product_id ): void {
247 349 $this->record_catalogue_object( 'product', $product_id, false );
248 350 }
249 351
@@ -418,12 +520,69 @@
418 520 public function record_order_created( int $order_id ): void {
419 521 $this->record_order_change( $order_id, 'hook:create', false );
420 522 }
421 523
422 - public function record_order_updated( int $order_id ): void {
423 - $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 );
424 539 }
425 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 +
426 585 public function record_order_deleted( int $order_id ): void {
427 586 $this->record_order_change( $order_id, 'hook:delete', true );
428 587 }
429 588
@@ -435,13 +594,11 @@
435 594 * Record an HPOS order's restore once the status change has settled.
436 595 *
437 596 * `woocommerce_untrash_order` fires BEFORE the data store restores the
438 597 * status, so the row cannot be written there. The restore then performs
439 - * MORE THAN ONE object save, so arming on the first
440 - * `woocommerce_after_order_object_save` whose status is not `trash`
441 - * captures a revision from part-way through the restore — anything a later
442 - * save changes is missing from it, and the journal advertises a revision
443 - * the order does not have.
598 + * MORE THAN ONE object save, so the journal row's modified_gmt must be read
599 + * from the SETTLED order for checkpoint ordering. The revision is computed
600 + * at pull time rather than stored here.
444 601 *
445 602 * Measured sequence for an HPOS untrash (status read from wc_orders):
446 603 *
447 604 * woocommerce_untrash_order stored=trash
@@ -465,21 +622,43 @@
465 622 };
466 623 add_action( 'woocommerce_order_status_changed', $handler, 10, 2 );
467 624 }
468 625
469 - 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 {
470 638 global $wpdb;
471 - $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 + }
472 654 $modified_date = $order ? $order->get_date_modified() : null;
473 655 $modified = $modified_date ? gmdate( 'Y-m-d H:i:s', $modified_date->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' );
474 - $revision = 'deleted';
475 -
476 - if ( $order && ! $deleted ) {
477 - $serializer = new Order_Serializer();
478 - $payload = $serializer->serialize_order( $order_id, new WP_REST_Request() );
479 - $sync_meta = $serializer->sync_metadata( $payload, $order_id, 'custom-pull', false, 0 );
480 - $revision = (string) $sync_meta['revision'];
481 - }
656 + // Order revisions are computed at pull time from the served payload (ADR 0033,
657 + // #1746) — an order journal row is a change pointer, not a content stamp.
658 + // 'deleted' is kept for wire compatibility (it flows into served checkpoints)
659 + // and diagnostics; the planner branches on the `deleted` flag, not this value.
660 + $revision = $deleted ? 'deleted' : '';
482 661
483 662 $now = gmdate( 'Y-m-d H:i:s' );
484 663 return false !== $wpdb->insert(
485 664 $this->table_name(),