| @@ -52,20 +52,28 @@ | ||
| 52 | 52 | * with ->key/->value, or arrays), or '' if none. Skips blank / invalid / a |
| 53 | 53 | * blank duplicate in favour of a later valid one. |
| 54 | 54 | */ |
| 55 | 55 | public static function read_valid_uuid_from_meta( array $meta_data ): string { |
| 56 | + $entry = self::first_valid_uuid_entry( $meta_data ); | |
| 57 | + | |
| 58 | + return null === $entry ? '' : (string) Meta_Entry::value( $entry ); | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * The record's CANONICAL uuid entry — the first meta entry carrying a valid | |
| 63 | + * uuid — or null. One selection rule for every reader: the served value, the | |
| 64 | + * entry the prune keeps and the provenance check all name the same entry. | |
| 65 | + * | |
| 66 | + * @return mixed|null | |
| 67 | + */ | |
| 68 | + private static function first_valid_uuid_entry( array $meta_data ) { | |
| 56 | 69 | foreach ( $meta_data as $meta ) { |
| 57 | - $key = Meta_Entry::key( $meta ); | |
| 58 | - if ( self::META_KEY !== $key ) { | |
| 59 | - continue; | |
| 70 | + if ( self::META_KEY === Meta_Entry::key( $meta ) && self::is_uuid( Meta_Entry::value( $meta ) ) ) { | |
| 71 | + return $meta; | |
| 60 | 72 | } |
| 61 | - $value = Meta_Entry::value( $meta ); | |
| 62 | - if ( self::is_uuid( $value ) ) { | |
| 63 | - return $value; | |
| 64 | - } | |
| 65 | 73 | } |
| 66 | 74 | |
| 67 | - return ''; | |
| 75 | + return null; | |
| 68 | 76 | } |
| 69 | 77 | |
| 70 | 78 | /** |
| 71 | 79 | * Ensure the record carries a stable, UNIQUELY-OWNED uuid: reuse a valid |
| @@ -78,8 +86,17 @@ | ||
| 78 | 86 | * that copied the meta), we treat it as needing a fresh one rather than serving |
| 79 | 87 | * a duplicate RxDB key. Injected so the branching stays unit-testable; the live |
| 80 | 88 | * wiring uses the $wpdb-backed self::uuid_owned_by_other. |
| 81 | 89 | * |
| 90 | + * $opts['trust_persisted'] (default false) settles ownership WITHOUT the | |
| 91 | + * detector when the uuid was loaded from this record's own meta row and is | |
| 92 | + * unchanged ({@see is_own_persisted_uuid}) — the ordinary save and read paths, | |
| 93 | + * where the detector re-proved a fact the row already stated at a cost linear | |
| 94 | + * in catalog size (#1805, ADR 0038). Leave it off where a loaded duplicate MUST | |
| 95 | + * be re-keyed: the collision backfill, the proxy stamper's in-response | |
| 96 | + * duplicates, and the V1 list lanes, whose no-shared-uuid-per-response contract | |
| 97 | + * has no other check. | |
| 98 | + * | |
| 82 | 99 | * @param mixed $object |
| 83 | 100 | */ |
| 84 | 101 | public static function ensure_uuid( $object, array $opts = array() ): string { |
| 85 | 102 | if ( ! \is_object( $object ) || ! method_exists( $object, 'get_meta_data' ) ) { |
| @@ -104,16 +121,23 @@ | ||
| 104 | 121 | */ |
| 105 | 122 | private static function ensure_uuid_without_user_lock( $object, array $opts ): string { |
| 106 | 123 | $collides = $opts['collides'] ?? null; |
| 107 | 124 | $persist = $opts['persist'] ?? true; |
| 108 | - $existing = self::read_valid_uuid_from_meta( (array) $object->get_meta_data() ); | |
| 109 | - if ( '' !== $existing && ! ( \is_callable( $collides ) && $collides( $existing, $object ) ) ) { | |
| 110 | - // Converge any duplicate uuid metas (e.g. a concurrent first-stamp) to | |
| 111 | - // the single canonical value — deterministic regardless of object-cache | |
| 112 | - // backend, so no cross-request lock is required for correctness. | |
| 113 | - self::prune_duplicate_uuid_meta( $object, $persist ); | |
| 125 | + $trust = ! empty( $opts['trust_persisted'] ); | |
| 126 | + $entry = self::first_valid_uuid_entry( (array) $object->get_meta_data() ); | |
| 127 | + $existing = null === $entry ? '' : (string) Meta_Entry::value( $entry ); | |
| 128 | + if ( '' !== $existing ) { | |
| 129 | + $owned = ! \is_callable( $collides ) | |
| 130 | + || ( $trust && self::is_own_persisted_uuid( $object, $entry ) ) | |
| 131 | + || ! $collides( $existing, $object ); | |
| 132 | + if ( $owned ) { | |
| 133 | + // Converge any duplicate uuid metas (e.g. a concurrent first-stamp) to | |
| 134 | + // the single canonical value — deterministic regardless of object-cache | |
| 135 | + // backend, so no cross-request lock is required for correctness. | |
| 136 | + self::prune_duplicate_uuid_meta( $object, $persist ); | |
| 114 | 137 | |
| 115 | - return $existing; | |
| 138 | + return $existing; | |
| 139 | + } | |
| 116 | 140 | } |
| 117 | 141 | if ( ! method_exists( $object, 'update_meta_data' ) ) { |
| 118 | 142 | return ''; |
| 119 | 143 | } |
| @@ -124,8 +148,26 @@ | ||
| 124 | 148 | if ( $persist && ! method_exists( $object, 'save_meta_data' ) ) { |
| 125 | 149 | return ''; |
| 126 | 150 | } |
| 127 | 151 | $uuid = self::generate_uuid(); |
| 152 | + if ( '' !== $existing ) { | |
| 153 | + // A re-key changes the record's client-side primary key (ADR 0038). Rare | |
| 154 | + // and consequential, so it is always on the record: which record, the | |
| 155 | + // identity it lost, the one it received. | |
| 156 | + // The commonest re-key is an unsaved clone (id 0), so the name is what | |
| 157 | + // identifies it after the fact. | |
| 158 | + $name = method_exists( $object, 'get_name' ) ? (string) $object->get_name() : ''; | |
| 159 | + Logger::log( | |
| 160 | + sprintf( | |
| 161 | + 'Re-keyed %s #%d%s: uuid %s is already owned by another record; it now carries %s.', | |
| 162 | + \get_class( $object ), | |
| 163 | + method_exists( $object, 'get_id' ) ? (int) $object->get_id() : 0, | |
| 164 | + '' === $name ? '' : ' (' . $name . ')', | |
| 165 | + $existing, | |
| 166 | + $uuid | |
| 167 | + ) | |
| 168 | + ); | |
| 169 | + } | |
| 128 | 170 | $object->update_meta_data( self::META_KEY, $uuid ); |
| 129 | 171 | if ( $persist ) { |
| 130 | 172 | call_user_func( array( $object, 'save_meta_data' ) ); |
| 131 | 173 | // A concurrent first-stamp may have persisted its own uuid between our |
| @@ -165,8 +207,11 @@ | ||
| 165 | 207 | if ( ! method_exists( $customer, 'get_id' ) || $user_id !== (int) $customer->get_id() ) { |
| 166 | 208 | return ''; |
| 167 | 209 | } |
| 168 | 210 | |
| 211 | + // No `trust_persisted` here: V1's customer list has no in-response | |
| 212 | + // duplicate check, so its "no two records share a uuid" contract rests on | |
| 213 | + // this detector (Test_Customers_Controller::test_customer_uuid_is_unique). | |
| 169 | 214 | return self::ensure_uuid( |
| 170 | 215 | $customer, |
| 171 | 216 | array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_user' ) ) |
| 172 | 217 | ); |
| @@ -375,9 +420,18 @@ | ||
| 375 | 420 | } |
| 376 | 421 | $collides = is_a( $object, 'WC_Abstract_Order' ) |
| 377 | 422 | ? array( __CLASS__, 'uuid_owned_by_other_order' ) |
| 378 | 423 | : array( __CLASS__, 'uuid_owned_by_other' ); |
| 379 | - $uuid = self::ensure_uuid( $object, array( 'collides' => $collides ) ); | |
| 424 | + // Read path over a record loaded from its own row: a loaded, unchanged uuid | |
| 425 | + // is trusted (ADR 0038). On the legacy CPT order store the detector is a | |
| 426 | + // full `wp_postmeta` uuid walk per served order (#1805). | |
| 427 | + $uuid = self::ensure_uuid( | |
| 428 | + $object, | |
| 429 | + array( | |
| 430 | + 'collides' => $collides, | |
| 431 | + 'trust_persisted' => true, | |
| 432 | + ) | |
| 433 | + ); | |
| 380 | 434 | |
| 381 | 435 | return '' === $uuid ? $payload : self::ensure_in_payload( $payload, $uuid ); |
| 382 | 436 | } |
| 383 | 437 | |
| @@ -413,8 +467,19 @@ | ||
| 413 | 467 | * `wc_orders_meta`, otherwise in `wp_postmeta`. `wc_get_orders()` with a |
| 414 | 468 | * `meta_query` is NOT supported on the CPT order datastore (it fires a |
| 415 | 469 | * `doing_it_wrong` and returns unfiltered results), so we query the meta table |
| 416 | 470 | * directly — the same shape the plugin's other order-uuid lookups use. |
| 471 | + * | |
| 472 | + * DELIBERATELY UNORDERED — do not add an `ORDER BY` back (#1725). Every caller | |
| 473 | + * asks a counting question ("does a DIFFERENT record hold this uuid?", "is this | |
| 474 | + * uuid ambiguous?"), so WHICH two ids come back is immaterial. `wp_postmeta` | |
| 475 | + * indexes `meta_key` but never `meta_value`, and `ORDER BY m.post_id ASC LIMIT 2` | |
| 476 | + * made the optimizer abandon the `meta_key` index for an id-ordered walk that | |
| 477 | + * expects to stop early. In the common case the uuid matches at most one row, so | |
| 478 | + * it never reaches two and walks the whole table: 887,404 rows and ~1.0 s per | |
| 479 | + * call on a real store, versus ~51 ms without the clause. HPOS escapes it only | |
| 480 | + * because `wc_orders_meta` carries a composite `(meta_key, meta_value)` index — | |
| 481 | + * by data, not by code — so the clause is gone from both branches. | |
| 417 | 482 | */ |
| 418 | 483 | public static function get_order_ids_by_uuid( string $uuid ): array { |
| 419 | 484 | global $wpdb; |
| 420 | 485 | if ( ! isset( $wpdb ) ) { |
| @@ -432,9 +497,9 @@ | ||
| 432 | 497 | "SELECT DISTINCT m.order_id FROM {$wpdb->prefix}wc_orders_meta m" |
| 433 | 498 | . " JOIN {$wpdb->prefix}wc_orders o ON o.id = m.order_id AND o.type = 'shop_order'" |
| 434 | 499 | . ' WHERE m.meta_key = %s AND m.meta_value = %s' |
| 435 | 500 | . " AND o.status NOT IN ('trash','auto-draft')" |
| 436 | - . ' ORDER BY m.order_id ASC LIMIT 2', | |
| 501 | + . ' LIMIT 2', | |
| 437 | 502 | self::META_KEY, |
| 438 | 503 | $uuid |
| 439 | 504 | ) |
| 440 | 505 | ); |
| @@ -444,9 +509,9 @@ | ||
| 444 | 509 | "SELECT DISTINCT m.post_id FROM {$wpdb->postmeta} m" |
| 445 | 510 | . " JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_type = 'shop_order'" |
| 446 | 511 | . ' WHERE m.meta_key = %s AND m.meta_value = %s' |
| 447 | 512 | . " AND p.post_status NOT IN ('trash','auto-draft')" |
| 448 | - . ' ORDER BY m.post_id ASC LIMIT 2', | |
| 513 | + . ' LIMIT 2', | |
| 449 | 514 | self::META_KEY, |
| 450 | 515 | $uuid |
| 451 | 516 | ) |
| 452 | 517 | ); |
| @@ -469,8 +534,14 @@ | ||
| 469 | 534 | return; |
| 470 | 535 | } |
| 471 | 536 | add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 ); |
| 472 | 537 | add_action( 'woocommerce_before_product_variation_object_save', array( __CLASS__, 'stamp_on_save' ), 10, 1 ); |
| 538 | + // A record leaving the trash must re-prove ownership: it was invisible to | |
| 539 | + // the detector while inactive, so another record may hold its uuid now. | |
| 540 | + // Both storage lanes — `untrashed_post` never fires for HPOS orders and | |
| 541 | + // `woocommerce_untrash_order` never fires for posts (ADR 0038). | |
| 542 | + add_action( 'untrashed_post', array( __CLASS__, 'recheck_ownership_after_untrash' ), 10, 1 ); | |
| 543 | + add_action( 'woocommerce_untrash_order', array( __CLASS__, 'recheck_order_ownership_after_untrash' ), 10, 1 ); | |
| 473 | 544 | } |
| 474 | 545 | |
| 475 | 546 | /** |
| 476 | 547 | * Before-save hook: ensure the WC object carries a unique uuid as part of the |
| @@ -475,8 +546,14 @@ | ||
| 475 | 546 | /** |
| 476 | 547 | * Before-save hook: ensure the WC object carries a unique uuid as part of the |
| 477 | 548 | * in-progress save (persist:false — the save itself writes it). |
| 478 | 549 | * |
| 550 | + * The ownership scan runs only for a uuid that did NOT come from this record's | |
| 551 | + * own persisted meta row (`trust_persisted`, {@see is_own_persisted_uuid}). It | |
| 552 | + * walks every uuid row in `wp_postmeta` (no `meta_value` index), so on every | |
| 553 | + * save it cost 0.46 s and 30k rows examined on a 30k-product store, 114 times | |
| 554 | + * an hour, for a fact the loaded row already stated (#1805, ADR 0038). | |
| 555 | + * | |
| 479 | 556 | * @param mixed $object |
| 480 | 557 | */ |
| 481 | 558 | public static function stamp_on_save( $object ): void { |
| 482 | 559 | self::ensure_uuid( |
| @@ -481,12 +558,122 @@ | ||
| 481 | 558 | public static function stamp_on_save( $object ): void { |
| 482 | 559 | self::ensure_uuid( |
| 483 | 560 | $object, |
| 484 | 561 | array( |
| 485 | - 'collides' => array( __CLASS__, 'uuid_owned_by_other' ), | |
| 486 | - 'persist' => false, | |
| 562 | + 'collides' => array( __CLASS__, 'uuid_owned_by_other' ), | |
| 563 | + 'persist' => false, | |
| 564 | + 'trust_persisted' => true, | |
| 487 | 565 | ) |
| 488 | 566 | ); |
| 567 | + } | |
| 568 | + | |
| 569 | + /** | |
| 570 | + * Re-prove uuid ownership for a post that just left the trash (products, | |
| 571 | + * variations, and orders on the legacy CPT store). | |
| 572 | + * | |
| 573 | + * A trashed record is not a live owner, so a clone or import made while it | |
| 574 | + * was in the trash legitimately kept the copied uuid — and the tills now key | |
| 575 | + * on that record. A native restore (wp-admin's Restore, `wp_untrash_post()`) | |
| 576 | + * persists the status change before any WC object save, so neither the write | |
| 577 | + * hook nor the trusted read path ever sees a trash→live transition: this hook | |
| 578 | + * is the one seam. It runs the detector once per restore — a rare event — and | |
| 579 | + * re-keys the RESTORED record when another live record owns its uuid, never | |
| 580 | + * the record the tills already hold (ADR 0038). | |
| 581 | + * | |
| 582 | + * @param mixed $post_id Restored post id (`untrashed_post`). | |
| 583 | + */ | |
| 584 | + public static function recheck_ownership_after_untrash( $post_id ): void { | |
| 585 | + $post_id = (int) $post_id; | |
| 586 | + $post_type = \function_exists( 'get_post_type' ) ? get_post_type( $post_id ) : ''; | |
| 587 | + if ( \in_array( $post_type, array( 'product', 'product_variation' ), true ) ) { | |
| 588 | + $object = \function_exists( 'wc_get_product' ) ? wc_get_product( $post_id ) : null; | |
| 589 | + $collides = array( __CLASS__, 'uuid_owned_by_other' ); | |
| 590 | + } elseif ( 'shop_order' === $post_type ) { | |
| 591 | + $object = \function_exists( 'wc_get_order' ) ? wc_get_order( $post_id ) : null; | |
| 592 | + $collides = array( __CLASS__, 'uuid_owned_by_other_order' ); | |
| 593 | + } else { | |
| 594 | + return; | |
| 595 | + } | |
| 596 | + if ( \is_object( $object ) && method_exists( $object, 'get_id' ) && (int) $object->get_id() === $post_id ) { | |
| 597 | + self::ensure_uuid( $object, array( 'collides' => $collides ) ); | |
| 598 | + } | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 602 | + * HPOS twin of {@see recheck_ownership_after_untrash}: `untrashed_post` never | |
| 603 | + * fires for orders in the orders table, and `woocommerce_untrash_order` fires | |
| 604 | + * BEFORE the data store restores the status (a detector run there would see a | |
| 605 | + * still-trashed row and, worse, the restore's own save would write the old meta | |
| 606 | + * back). Arm a one-shot on the order's first live object save and re-prove | |
| 607 | + * ownership then — the same seam {@see Integrity_Digest::record_order_untrashed} | |
| 608 | + * uses. | |
| 609 | + * | |
| 610 | + * @param mixed $order_id Order being restored (`woocommerce_untrash_order`). | |
| 611 | + */ | |
| 612 | + public static function recheck_order_ownership_after_untrash( $order_id ): void { | |
| 613 | + $order_id = (int) $order_id; | |
| 614 | + $handler = static function ( $order ) use ( $order_id, &$handler ): void { | |
| 615 | + if ( ! \is_object( $order ) || ! method_exists( $order, 'get_id' ) || ! method_exists( $order, 'get_status' ) | |
| 616 | + || (int) $order->get_id() !== $order_id || 'trash' === $order->get_status() ) { | |
| 617 | + return; | |
| 618 | + } | |
| 619 | + remove_action( 'woocommerce_after_order_object_save', $handler ); | |
| 620 | + self::ensure_uuid( $order, array( 'collides' => array( __CLASS__, 'uuid_owned_by_other_order' ) ) ); | |
| 621 | + }; | |
| 622 | + add_action( 'woocommerce_after_order_object_save', $handler ); | |
| 623 | + } | |
| 624 | + | |
| 625 | + /** | |
| 626 | + * True when $entry — the record's canonical uuid entry — was READ from this | |
| 627 | + * record's own meta row and has not been changed in memory since: the uuid is | |
| 628 | + * already this record's persisted identity, not a value that arrived by copy. | |
| 629 | + * | |
| 630 | + * The ownership detector exists to catch a uuid that reached a record some | |
| 631 | + * other way, and every such provenance fails this test: a duplicated object | |
| 632 | + * (WooCommerce's "Duplicate" clones the meta with its ids cleared), an importer | |
| 633 | + * rewriting the value in memory (a tracked change on the entry), a record with | |
| 634 | + * no id yet, a record returning from the trash. What passes is the ordinary | |
| 635 | + * save or read — a stock change, a price edit, a REST update, a served record — | |
| 636 | + * where the detector re-proved a fact at a cost linear in catalog size. | |
| 637 | + * | |
| 638 | + * A copy made WITHOUT hooks (direct SQL, a migration tool) passes too, on both | |
| 639 | + * records: neither save re-keys it. Deliberate (ADR 0038): the detector caught | |
| 640 | + * that shape only when one of the two next saved, and re-keyed whichever that | |
| 641 | + * was — the original as readily as the copy. The collision backfill | |
| 642 | + * (`/uuid/backfill?mode=collisions`) walks the store once in bounded pages and | |
| 643 | + * re-keys the later copy, never the owner; it is the repair for that shape. | |
| 644 | + * | |
| 645 | + * Duck-typed on WC_Data / WC_Meta_Data (`get_id`, `get_changes`, `get_data`, | |
| 646 | + * `->id`): a bare array or a fake without change tracking is never trusted. | |
| 647 | + * | |
| 648 | + * @param mixed $object | |
| 649 | + * @param mixed $entry | |
| 650 | + */ | |
| 651 | + private static function is_own_persisted_uuid( $object, $entry ): bool { | |
| 652 | + if ( ! \is_object( $object ) || ! method_exists( $object, 'get_id' ) || (int) $object->get_id() <= 0 ) { | |
| 653 | + return false; | |
| 654 | + } | |
| 655 | + // A record coming back from trash/auto-draft was invisible to the ownership | |
| 656 | + // scan while inactive (inactive rows are not live owners), so another record | |
| 657 | + // may have adopted its uuid in the meantime — and that record is what the | |
| 658 | + // tills now key on. The transition back to live is the one ordinary save | |
| 659 | + // that must re-prove ownership; the loaded status is still in get_data() | |
| 660 | + // because the before-save hook fires ahead of apply_changes(). | |
| 661 | + if ( method_exists( $object, 'get_changes' ) && method_exists( $object, 'get_data' ) ) { | |
| 662 | + $changes = (array) $object->get_changes(); | |
| 663 | + if ( isset( $changes['status'] ) ) { | |
| 664 | + $loaded = (array) $object->get_data(); | |
| 665 | + if ( \in_array( (string) ( $loaded['status'] ?? '' ), array( 'trash', 'auto-draft' ), true ) ) { | |
| 666 | + return false; | |
| 667 | + } | |
| 668 | + } | |
| 669 | + } | |
| 670 | + // Only the canonical entry's provenance decides; a trailing duplicate is | |
| 671 | + // pruned by the save either way. | |
| 672 | + return \is_object( $entry ) | |
| 673 | + && method_exists( $entry, 'get_changes' ) | |
| 674 | + && ! empty( $entry->id ) | |
| 675 | + && array() === $entry->get_changes(); | |
| 489 | 676 | } |
| 490 | 677 | |
| 491 | 678 | /** |
| 492 | 679 | * True when $uuid is already stored as `_woocommerce_pos_uuid` on a DIFFERENT |