| @@ -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" |
| @@ -159,8 +185,56 @@ | ||
| 159 | 185 | ) |
| 160 | 186 | ); |
| 161 | 187 | } |
| 162 | 188 | |
| 189 | + /** | |
| 190 | + * Append one tombstone per catalogue post id, in a single statement. | |
| 191 | + * | |
| 192 | + * The per-record `record_post_deleted()` path loads a `WC_Product` for the revision stamp, which | |
| 193 | + * is fine for the handful of records one settings write moves and hopeless for the whole hidden | |
| 194 | + * set of a store that keeps thousands of products online-only. This is the bulk form, shaped like | |
| 195 | + * `append_customer_updates_for_all_users()`: one INSERT ... SELECT, no revision (a tombstone | |
| 196 | + * carries no state the client compares), and the post type read from `wp_posts` so a stale id in | |
| 197 | + * the merchant's list cannot announce a change to an unrelated record. | |
| 198 | + * | |
| 199 | + * @param int[] $ids Product / variation post ids. | |
| 200 | + */ | |
| 201 | + public function append_catalogue_tombstones( array $ids ): bool { | |
| 202 | + global $wpdb; | |
| 203 | + | |
| 204 | + $ids = array_values( | |
| 205 | + array_unique( | |
| 206 | + array_filter( | |
| 207 | + array_map( 'intval', $ids ), | |
| 208 | + static function ( int $id ): bool { | |
| 209 | + return $id > 0; | |
| 210 | + } | |
| 211 | + ) | |
| 212 | + ) | |
| 213 | + ); | |
| 214 | + if ( array() === $ids ) { | |
| 215 | + return true; | |
| 216 | + } | |
| 217 | + | |
| 218 | + $now = gmdate( 'Y-m-d H:i:s' ); | |
| 219 | + $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); | |
| 220 | + | |
| 221 | + return false !== $wpdb->query( | |
| 222 | + $wpdb->prepare( | |
| 223 | + 'INSERT INTO ' . $this->table_name() | |
| 224 | + . ' (object_type, object_id, deleted, revision, modified_gmt, origin, created_gmt)' | |
| 225 | + . " SELECT CASE p.post_type WHEN 'product_variation' THEN 'variation' ELSE 'product' END," | |
| 226 | + . " p.ID, 1, '', %s, 'visibility-seed', %s" | |
| 227 | + . " FROM {$wpdb->posts} p" | |
| 228 | + . " WHERE p.post_type IN ('product','product_variation') AND p.ID IN ({$placeholders})" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %d placeholder list generated from count(); the ids are bound below. | |
| 229 | + . ' ORDER BY p.ID', | |
| 230 | + $now, | |
| 231 | + $now, | |
| 232 | + ...$ids | |
| 233 | + ) | |
| 234 | + ); | |
| 235 | + } | |
| 236 | + | |
| 163 | 237 | public function register_hooks(): void { |
| 164 | 238 | add_action( 'woocommerce_new_product', array( $this, 'record_product_created' ), 10, 1 ); |
| 165 | 239 | add_action( 'woocommerce_update_product', array( $this, 'record_product_updated' ), 10, 1 ); |
| 166 | 240 | add_action( 'woocommerce_new_product_variation', array( $this, 'record_variation_created' ), 10, 1 ); |
| @@ -188,14 +262,90 @@ | ||
| 188 | 262 | add_action( 'remove_user_role', array( $this, 'record_customer_role_removed' ), 10, 2 ); |
| 189 | 263 | add_action( 'woocommerce_update_customer', array( $this, 'record_customer_updated' ), 10, 1 ); |
| 190 | 264 | add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 ); |
| 191 | 265 | add_action( 'woocommerce_new_order', array( $this, 'record_order_created' ), 10, 1 ); |
| 192 | - 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 ); | |
| 193 | 274 | add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 194 | 275 | add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 ); |
| 195 | 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 ); | |
| 196 | 278 | } |
| 197 | 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 | + | |
| 198 | 348 | public function record_product_created( int $product_id ): void { |
| 199 | 349 | $this->record_catalogue_object( 'product', $product_id, false ); |
| 200 | 350 | } |
| 201 | 351 | |
| @@ -370,12 +520,69 @@ | ||
| 370 | 520 | public function record_order_created( int $order_id ): void { |
| 371 | 521 | $this->record_order_change( $order_id, 'hook:create', false ); |
| 372 | 522 | } |
| 373 | 523 | |
| 374 | - public function record_order_updated( int $order_id ): void { | |
| 375 | - $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 ); | |
| 376 | 539 | } |
| 377 | 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 | + | |
| 378 | 585 | public function record_order_deleted( int $order_id ): void { |
| 379 | 586 | $this->record_order_change( $order_id, 'hook:delete', true ); |
| 380 | 587 | } |
| 381 | 588 | |
| @@ -387,13 +594,11 @@ | ||
| 387 | 594 | * Record an HPOS order's restore once the status change has settled. |
| 388 | 595 | * |
| 389 | 596 | * `woocommerce_untrash_order` fires BEFORE the data store restores the |
| 390 | 597 | * status, so the row cannot be written there. The restore then performs |
| 391 | - * MORE THAN ONE object save, so arming on the first | |
| 392 | - * `woocommerce_after_order_object_save` whose status is not `trash` | |
| 393 | - * captures a revision from part-way through the restore — anything a later | |
| 394 | - * save changes is missing from it, and the journal advertises a revision | |
| 395 | - * 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. | |
| 396 | 601 | * |
| 397 | 602 | * Measured sequence for an HPOS untrash (status read from wc_orders): |
| 398 | 603 | * |
| 399 | 604 | * woocommerce_untrash_order stored=trash |
| @@ -417,21 +622,43 @@ | ||
| 417 | 622 | }; |
| 418 | 623 | add_action( 'woocommerce_order_status_changed', $handler, 10, 2 ); |
| 419 | 624 | } |
| 420 | 625 | |
| 421 | - 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 { | |
| 422 | 638 | global $wpdb; |
| 423 | - $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 | + } | |
| 424 | 654 | $modified_date = $order ? $order->get_date_modified() : null; |
| 425 | 655 | $modified = $modified_date ? gmdate( 'Y-m-d H:i:s', $modified_date->getTimestamp() ) : gmdate( 'Y-m-d H:i:s' ); |
| 426 | - $revision = 'deleted'; | |
| 427 | - | |
| 428 | - if ( $order && ! $deleted ) { | |
| 429 | - $serializer = new Order_Serializer(); | |
| 430 | - $payload = $serializer->serialize_order( $order_id, new WP_REST_Request() ); | |
| 431 | - $sync_meta = $serializer->sync_metadata( $payload, $order_id, 'custom-pull', false, 0 ); | |
| 432 | - $revision = (string) $sync_meta['revision']; | |
| 433 | - } | |
| 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' : ''; | |
| 434 | 661 | |
| 435 | 662 | $now = gmdate( 'Y-m-d H:i:s' ); |
| 436 | 663 | return false !== $wpdb->insert( |
| 437 | 664 | $this->table_name(), |