PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Integrity_Digest.php

Integrity_Digest.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/Sync/Integrity_Digest.php

755 lines 31.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync store component.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WCPOS\WooCommercePOS\Logger;
11
12 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
13 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- Queries use internal table names and generated SQL fragments.
14 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database failures are passed to exceptions, not rendered.
15
16 use RuntimeException;
17
18 /**
19 * Hash-backed range-checksum support: stored per-record content digests.
20 *
21 * STORES a digest of each product/variation's raw DB row, marked dirty by the
22 * same save/delete hooks class-change-log.php uses and written at the request
23 * boundary (see $pending), so the integrity scan
24 * can compare — entirely in SQL — the aggregate of CURRENT raw-row digests
25 * against the aggregate of STORED digests per id-range bucket. If hooks
26 * fired for every write, stored == current (and sequence-log already
27 * reported the change); a bucket mismatch therefore means exactly "content
28 * changed without hooks firing" — the sql-bypass signature — at GROUP BY
29 * prices instead of revision-hash's full-hydration prices. Because the write
30 * lands at the boundary, a hook-less write later in the SAME request is
31 * absorbed into that request's digest; the scan catches bypasses between
32 * requests, which is where they happen (a direct SQL job, an importer).
33 *
34 * The digest basis is deliberately the RAW DB ROW, NOT the filtered REST
35 * payload: this signal is detection-only (discovery of WHERE drift
36 * happened, ADR 0003 "discovery, never values"); hydration of anything the
37 * POS trusts still goes through the filtered REST path. The flip side is
38 * documented too: a raw-row digest cannot see a plugin changing the served
39 * representation without touching the row — that staleness case remains
40 * revision-hash territory.
41 *
42 * This class is the WRITE half. The READ half — every question the REST read
43 * surface asks of the store, plus the canonical digest SQL both halves share —
44 * lives in {@see Digest_Index}. The SQL-fragment accessors that used to hang off
45 * this class are kept as deprecated delegates so existing callers keep working.
46 */
47 final class Integrity_Digest {
48
49 /**
50 * Wall-clock ms spent inside the digest write hooks during the CURRENT
51 * request. Read (and reset) by the product-edit fixture for the
52 * hook-overhead bench's per-component breakdown. Two microtime() calls
53 * per hook fire — negligible against the INSERT…SELECT it wraps.
54 */
55 public static float $request_write_ms = 0.0;
56
57 /**
58 * @see Digest_Index::DIGESTED_META_KEYS The BASELINE key set.
59 * The formula the digest actually uses is Digest_Index::digested_meta_keys(),
60 * which folds in the configured barcode key (mono#1234).
61 */
62 public const DIGESTED_META_KEYS = Digest_Index::DIGESTED_META_KEYS;
63
64 /** @see Digest_Index::CUSTOMER_DIGESTED_META_KEYS The digest formula's home. */
65 public const CUSTOMER_DIGESTED_META_KEYS = Digest_Index::CUSTOMER_DIGESTED_META_KEYS;
66
67 /** @see Digest_Index::ORDER_DIGESTED_META_KEYS The digest formula's home. */
68 public const ORDER_DIGESTED_META_KEYS = Digest_Index::ORDER_DIGESTED_META_KEYS;
69
70 /** @see Digest_Index::OBJECT_TYPES_SQL The product-space object types. */
71 public const OBJECT_TYPES_SQL = Digest_Index::OBJECT_TYPES_SQL;
72
73 public const REBUILD_HOOK = 'wcpos_integrity_digest_rebuild';
74 public const REBUILD_LOCK = 'wcpos_integrity_digest_rebuild_lock';
75 public const REBUILD_LOCK_TTL = 300;
76
77 /**
78 * The read half + the canonical digest SQL. The write statements below compose
79 * their INSERT…SELECT sources from it, so stored and current digests are
80 * computed by ONE expression — the invariant the whole scan rests on.
81 */
82 private Digest_Index $index;
83
84 /**
85 * Digests owed but not yet written, keyed by blog, type and id.
86 *
87 * A stored digest is a pure function of the settled record, so only the
88 * LAST upsert in a request carries information — yet one Store API checkout
89 * ran the order INSERT…SELECT eleven times (35 ms) and, with account
90 * creation, the customer one six more times (measured 2026-09-03 on
91 * dev-next). Upserts land on {@see flush_pending_digests()}: at shutdown
92 * and before {@see Digest_Index::read_digests()}, so the pull lane never
93 * stamps a stale `_rxdb_digest`. Product and variation digests ride the
94 * same queue: `wc_reduce_stock_levels()` saves the quantity, then the stock
95 * status — two INSERT…SELECT statements per purchased product at checkout
96 * (measured 2026-09-03). The v2 write lane reads digests through that same
97 * read method, so its serializer still stamps a fresh `_rxdb_digest`.
98 *
99 * Static so the read path can flush without holding the observer. The first
100 * queuing instance binds the writer, including after shutdown; all instances
101 * write the same table. An empty shutdown uses a default instance instead.
102 * See Request_Write_Queue for the queue mechanics.
103 */
104 private static ?Request_Write_Queue $pending = null;
105
106 /**
107 * The most distinct records held; the next distinct record flushes them first.
108 * Sized for the
109 * realistic per-request maximum (a checkout touches an order and a customer;
110 * a REST batch a few dozen records) while keeping a WP-CLI import's deferred
111 * SQL and memory bounded.
112 */
113 public const PENDING_DIGEST_FLUSH_THRESHOLD = 50;
114
115 public function __construct( ?Digest_Index $index = null ) {
116 $this->index = $index ?? new Digest_Index();
117 }
118
119 public function table_name(): string {
120 return $this->index->table_name();
121 }
122
123 /**
124 * Separate current-state table rather than a column on the change-log:
125 * the change-log is an append-only event journal (many rows per object,
126 * tombstones included) while the stored digest is exactly one row per
127 * live object — different cardinality and lifecycle. Folding the digest
128 * into the log would force a latest-row-per-object subquery on every
129 * scan, destroying the GROUP BY price this design exists for.
130 *
131 * digest is BIGINT UNSIGNED holding a 64-bit value (top 16 hex of MD5): integer
132 * storage keeps the BIT_XOR bucket aggregate a pure integer fold with
133 * constant-size state, where a CHAR hash would need GROUP_CONCAT (and
134 * its max_len truncation hazard) to aggregate.
135 */
136 public function schema_sql( string $table_name, string $charset_collate = '' ): string {
137 return "CREATE TABLE {$table_name} (\n"
138 . " object_type VARCHAR(20) NOT NULL,\n"
139 . " object_id BIGINT UNSIGNED NOT NULL,\n"
140 . " digest BIGINT UNSIGNED NOT NULL,\n"
141 . " updated_gmt DATETIME NOT NULL,\n"
142 . " PRIMARY KEY (object_type, object_id),\n"
143 . " KEY object_id (object_id)\n"
144 . ") {$charset_collate};";
145 }
146
147 public function install(): void {
148 global $wpdb;
149 if ( ! function_exists( 'dbDelta' ) ) {
150 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
151 }
152 dbDelta( $this->schema_sql( $this->table_name(), $wpdb->get_charset_collate() ) );
153 }
154
155
156 /**
157 * Same save/delete hooks the change-log listens to (products and
158 * variations only — tax rates live in their own table outside the
159 * wp_posts id space this scan buckets; they stay covered by the plain
160 * range-checksum candidate, whose checksum covers the full rate row).
161 */
162 public function register_hooks(): void {
163 add_action( 'woocommerce_new_product', array( $this, 'record_post_saved' ), 10, 1 );
164 add_action( 'woocommerce_update_product', array( $this, 'record_post_saved' ), 10, 1 );
165 add_action( 'woocommerce_new_product_variation', array( $this, 'record_post_saved' ), 10, 1 );
166 add_action( 'woocommerce_update_product_variation', array( $this, 'record_post_saved' ), 10, 1 );
167 // Untrash does not reliably re-fire woocommerce_update_product; the
168 // upsert is a no-op for non-live rows, so hooking it is free.
169 add_action( 'untrashed_post', array( $this, 'record_post_untrashed' ), 10, 1 );
170 add_action( 'wp_trash_post', array( $this, 'record_post_deleted' ), 10, 1 );
171 add_action( 'before_delete_post', array( $this, 'record_post_deleted' ), 10, 1 );
172
173 // Leg-3 phase 7 (ADR 0015): ALL WordPress users are POS customers under
174 // #1379 (1.9 parity). Saves and role changes idempotently upsert their
175 // digest; only delete_user removes it.
176 add_action( 'user_register', array( $this, 'record_customer_saved' ), 10, 1 );
177 add_action( 'profile_update', array( $this, 'record_customer_saved' ), 10, 1 );
178 add_action( 'woocommerce_created_customer', array( $this, 'record_customer_saved' ), 10, 1 );
179 add_action( 'woocommerce_new_customer', array( $this, 'record_customer_saved' ), 10, 1 );
180 add_action( 'woocommerce_update_customer', array( $this, 'record_customer_saved' ), 10, 1 );
181 add_action( 'set_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
182 // add_role()/remove_role() fire ONLY add_user_role/remove_user_role, so
183 // register both to capture membership changes in the served record.
184 add_action( 'add_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
185 add_action( 'remove_user_role', array( $this, 'record_customer_saved' ), 10, 1 );
186 add_action( 'delete_user', array( $this, 'record_customer_deleted' ), 10, 1 );
187
188 // Leg-3 phase 7 (ADR 0015): order digest maintenance. Storage-agnostic WC order hooks (fire under
189 // HPOS AND CPT), matching the sync-index's order hooks. upsert/delete are idempotent (no dedup).
190 add_action( 'woocommerce_new_order', array( $this, 'record_order_saved' ), 10, 1 );
191 add_action( 'woocommerce_update_order', array( $this, 'record_order_saved' ), 10, 1 );
192 add_action( 'woocommerce_before_trash_order', array( $this, 'record_order_deleted' ), 10, 1 );
193 add_action( 'woocommerce_before_delete_order', array( $this, 'record_order_deleted' ), 10, 1 );
194 // Untrash recreation: `untrashed_post` (handled by record_post_untrashed)
195 // never fires for COT orders — without the HPOS twin hook a restored
196 // order's digest is never recreated and integrity scans treat it as
197 // deleted forever.
198 add_action( 'woocommerce_untrash_order', array( $this, 'record_order_untrashed' ), 10, 1 );
199 // Request boundary for the coalesced digest upserts (see
200 // $pending). LAST on shutdown: WooCommerce saves the customer at
201 // 10 and the session at 20. Zero accepted args: do_action( 'shutdown' )
202 // passes an empty string otherwise.
203 add_action( 'shutdown', array( __CLASS__, 'flush_pending_digests_at_shutdown' ), PHP_INT_MAX, 0 );
204 }
205
206 /**
207 * Recreate a COT order's digest once its restore completes.
208 *
209 * `woocommerce_untrash_order` fires BEFORE the data store restores the
210 * status, and the restore's internal save fires no observer hook we bind
211 * (verified: `woocommerce_update_order` does not fire there) — so an
212 * immediate upsert would read a still-trashed row and write nothing. Arm a
213 * one-shot on the order's first object save after it leaves the trash and
214 * upsert then.
215 *
216 * @param int $order_id Order being restored.
217 */
218 public function record_order_untrashed( int $order_id ): void {
219 $handler = function ( $order ) use ( $order_id, &$handler ): void {
220 if ( ! \is_object( $order ) || ! method_exists( $order, 'get_id' ) || ! method_exists( $order, 'get_status' ) || (int) $order->get_id() !== $order_id || 'trash' === $order->get_status() ) {
221 return;
222 }
223 remove_action( 'woocommerce_after_order_object_save', $handler );
224 $this->record_order_saved( $order_id );
225 };
226 add_action( 'woocommerce_after_order_object_save', $handler );
227 }
228
229 /**
230 * Cron entry point for rebuilding unexpectedly empty or stale product digests.
231 */
232 public static function run_scheduled_rebuild(): void {
233 $lease = get_transient( self::REBUILD_LOCK );
234 try {
235 ( new self() )->rebuild( true );
236 } catch ( \Throwable $exception ) {
237 Logger::error( 'WCPOS sync: scheduled integrity digest rebuild failed: ' . $exception->getMessage() );
238 } finally {
239 self::release_rebuild_lock( $lease );
240 }
241 }
242
243 /**
244 * Release the rebuild lease only if this run still owns it — a rebuild that
245 * outlived the lock TTL must not delete a successor's fresh lease.
246 *
247 * @param mixed $lease The lease value captured when this run started.
248 */
249 public static function release_rebuild_lock( $lease ): void {
250 if ( get_transient( self::REBUILD_LOCK ) === $lease ) {
251 delete_transient( self::REBUILD_LOCK );
252 }
253 }
254
255 /**
256 * Wire THE digest stamper onto both served read lanes (#421 increment 3).
257 *
258 * ONE named static serves every digest id-space: it resolves the registry row
259 * from the lane's resource slug, so a collection that gains a digest group is
260 * stamped by adding a row and nothing else. The composed callback name this
261 * used to build (`stamp_proxy_{object_type}_digests`) could name a method that
262 * did not exist — add_filter() does not validate callables, so the miss only
263 * surfaced as a fatal at apply_filters() time, on a catalogue proxy read.
264 *
265 * Both public filter names stay live and both are registered here, so the order
266 * pull lane is wired by the same call as the proxy lane instead of by hand in
267 * Init. Returns the digest-and-proxy collections (the wiring golden pins them).
268 *
269 * @return string[] Collections whose served records carry a stored digest.
270 */
271 public static function register_proxy_digest_stampers(): array {
272 $registered = array();
273 foreach ( Collections::with( 'digest' ) as $collection => $row ) {
274 if ( ! isset( $row['proxy'] ) ) {
275 continue;
276 }
277 $registered[] = $collection;
278 }
279 add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_digests' ), 10, 3 );
280 add_filter( 'woocommerce_pos_sync_order_pull_payloads', array( __CLASS__, 'stamp_digests' ), 10, 3 );
281
282 return $registered;
283 }
284
285 /**
286 * Detach THE digest stamper from both served read lanes.
287 *
288 * The teardown twin of {@see register_proxy_digest_stampers()}, matching the
289 * `unregister_*` seams {@see Revision} and {@see Proxy_Uuid_Stamper} already
290 * expose. `Augmentation_Pipeline::reset()` only removes the projections the
291 * pipeline itself installed, so without this a caller that installs the real
292 * pipeline — a test wiring the production read lane — cannot unwind it and
293 * leaks this filter into everything that runs after it.
294 */
295 public static function unregister_proxy_digest_stampers(): void {
296 remove_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'stamp_digests' ), 10 );
297 remove_filter( 'woocommerce_pos_sync_order_pull_payloads', array( __CLASS__, 'stamp_digests' ), 10 );
298 }
299
300 /**
301 * Attach each served record's stored 64-bit digest as a top-level `_rxdb_digest`
302 * string, so the client seeds its existence-reconcile manifest (ADR 0014 Leg 3)
303 * as records flow through the NORMAL pull — no separate fetch. The client reads
304 * it into the sidecar manifest; it is NOT persisted into the document. A record
305 * with no stored digest yet simply carries no `_rxdb_digest`.
306 *
307 * The lane's resource slug picks the id-space (the registry owns the mapping,
308 * including the slug traps), and a resource with no digest group — or none at
309 * all — returns the payload untouched.
310 *
311 * @param mixed $data Served list of records.
312 * @param mixed $resource Lane resource slug.
313 * @param mixed $request Request context.
314 *
315 * @return mixed
316 */
317 public static function stamp_digests( $data, $resource = '', $request = null ) {
318 if ( ! \is_array( $data ) || ! \is_string( $resource ) || '' === $resource ) {
319 return $data;
320 }
321 $row = Collections::by_proxy_slug( $resource );
322 if ( null === $row || ! isset( $row['digest'] ) ) {
323 return $data;
324 }
325 $ids = array();
326 foreach ( $data as $record ) {
327 if ( \is_array( $record ) && isset( $record['id'] ) ) {
328 $ids[] = (int) $record['id'];
329 }
330 }
331 if ( array() === $ids ) {
332 return $data;
333 }
334 $digests = ( new Digest_Index() )->read_digests( $row['_collection'], $ids );
335 foreach ( $data as $index => $record ) {
336 if ( \is_array( $record ) && isset( $record['id'] ) && isset( $digests[ (int) $record['id'] ] ) ) {
337 $data[ $index ]['_rxdb_digest'] = $digests[ (int) $record['id'] ];
338 }
339 }
340
341 return $data;
342 }
343
344 /**
345 * Canonical per-CUSTOMER digest SELECT (ADR 0015, Leg-3 phase 7).
346 *
347 * @deprecated Use {@see Digest_Index::customer_digest_select_sql()}.
348 */
349 public function customer_digest_select_sql( string $where_sql = '' ): string {
350 return $this->index->customer_digest_select_sql( $where_sql );
351 }
352
353 /**
354 * Customer digest maintenance (ADR 0015, Leg-3 phase 7) — every WordPress
355 * user is a POS customer, so saves and role changes always upsert.
356 */
357 /** Owe the customer's digest; it is written once, on flush (see $pending). */
358 public function record_customer_saved( int $user_id ): void {
359 $this->defer( self::pending_type( 'customers' ), $user_id );
360 }
361
362 /**
363 * Queue one digest upsert, or write it now if the boundary has passed.
364 *
365 * @param string $type Registry digest object-types key.
366 * @param int $id Record id.
367 */
368 private function defer( string $type, int $id ): void {
369 self::queue( $this )->owe( $type, $id );
370 }
371
372 /** Bind the first queuing instance, or a default for an empty shutdown. */
373 private static function queue( ?self $writer = null ): Request_Write_Queue {
374 if ( null === self::$pending ) {
375 $writer = $writer ?? new self();
376 self::$pending = new Request_Write_Queue(
377 self::PENDING_DIGEST_FLUSH_THRESHOLD,
378 function ( $type, $id ) use ( $writer ): void {
379 $writer->upsert_pending( $type, $id );
380 }
381 );
382 }
383 return self::$pending;
384 }
385
386 /** One queue discriminator per digest id-space, including shared product/variation ids. */
387 private static function pending_type( string $collection ): string {
388 return implode( ',', Collections::row( $collection )['digest']['object_types'] );
389 }
390
391 /** One queued upsert, under the observer's fail-open posture. */
392 private function upsert_pending( string $type, int $id ): void {
393 $this->observe(
394 function () use ( $type, $id ): void {
395 foreach ( Collections::with( 'digest' ) as $collection => $row ) {
396 if ( self::pending_type( $collection ) === $type ) {
397 $this->upsert_for( $row['digest']['id_space'], $id );
398 return;
399 }
400 }
401 Logger::warning( 'WCPOS sync: no digest collection matches queued type: ' . $type );
402 }
403 );
404 }
405
406 /**
407 * Write every owed digest.
408 *
409 * Called from the shutdown flush, from {@see Digest_Index::read_digests()}
410 * before it reads, and before a new record exceeds queue capacity. Writes go
411 * through the instance that first queued (so an injected Digest_Index is
412 * honoured) and under the blog each entry was recorded on. Each upsert keeps
413 * the observer's fail-open posture: a failure is logged and the scan
414 * self-heals. Safe to call repeatedly — a flushed digest is no longer pending.
415 */
416 public static function flush_pending_digests(): void {
417 if ( null !== self::$pending ) {
418 self::$pending->flush();
419 }
420 }
421
422 /**
423 * The `shutdown` callback: flush, then write every later save immediately.
424 */
425 public static function flush_pending_digests_at_shutdown(): void {
426 self::queue()->flush_at_shutdown();
427 }
428
429 /**
430 * Discard per-request coalescing state. Tests only: the PHPUnit process
431 * never reaches `shutdown`, so the static queue would
432 * leak between test cases otherwise.
433 *
434 * @internal
435 */
436 public static function reset_request_state(): void {
437 self::$pending = null;
438 }
439
440 public function record_customer_deleted( int $user_id ): void {
441 $this->delete_for( 'customers', $user_id );
442 }
443
444 /**
445 * Observation hooks must never break the host write that fired them: a
446 * broken or missing digest store is a sync problem (the integrity scan and
447 * the health gate surface it), not a reason to fatal a WooCommerce save.
448 * The ops paths (rebuild/prune) keep throwing — they run on demand and
449 * want the loudness.
450 *
451 * @param callable $observer The digest write to attempt.
452 */
453 private function observe( callable $observer ): void {
454 try {
455 $observer();
456 } catch ( \Throwable $e ) {
457 Logger::error( 'Sync digest observer failed (sync will self-heal via scan/rebuild): ' . $e->getMessage() );
458 }
459 }
460
461 /**
462 * Order digest maintenance (ADR 0015, Leg-3 phase 7). The WC order hooks are storage-agnostic (fire
463 * under HPOS AND CPT); the digest SQL's `type='shop_order'` filter makes the upsert a no-op for any
464 * non-order, so no type re-check is needed here.
465 */
466 /** Owe the order's digest; it is written once, on flush (see $pending). */
467 public function record_order_saved( int $order_id ): void {
468 $this->defer( self::pending_type( 'orders' ), $order_id );
469 }
470
471 public function record_order_deleted( int $order_id ): void {
472 $this->delete_for( 'orders', $order_id );
473 }
474
475 /**
476 * Order analogue of {@see upsert_customer_digest} (HPOS or CPT).
477 *
478 * @deprecated Use record_order_saved().
479 */
480 public function upsert_order_digest( int $order_id ): void {
481 $this->upsert_for( 'orders', $order_id );
482 }
483
484 /**
485 * Owe the product's or variation's digest; it is written once, on flush (see
486 * $pending). Both share the registry's queue key: the upsert's SQL
487 * derives the stored object_type from the row, so nothing here needs to.
488 */
489 public function record_post_saved( int $post_id ): void {
490 $this->defer( self::pending_type( 'products' ), $post_id );
491 }
492
493 public function record_post_untrashed( int $post_id ): void {
494 $post_type = get_post_type( $post_id );
495 if ( 'shop_order' === $post_type ) {
496 $this->record_order_saved( $post_id );
497 return;
498 }
499 if ( in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
500 $this->record_post_saved( $post_id );
501 }
502 }
503
504 public function record_post_deleted( int $post_id ): void {
505 $post_type = get_post_type( $post_id );
506 if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
507 return;
508 }
509 $this->delete_for( 'products', $post_id, 'product_variation' === $post_type );
510 }
511
512 /** Cancel an owed upsert and remove the registry-selected stored row. */
513 private function delete_for( string $collection, int $id, bool $child = false ): void {
514 if ( null !== self::$pending ) {
515 self::$pending->drop( self::pending_type( $collection ), $id );
516 }
517 $this->observe(
518 function () use ( $collection, $id, $child ): void {
519 global $wpdb;
520 $row = Collections::row( $collection );
521 $digest = $row['digest'];
522 $started = microtime( true );
523 $deleted = $wpdb->delete(
524 $this->table_name(),
525 array(
526 'object_type' => $child ? $digest['child_type'] : $row['object_type'],
527 'object_id' => $id,
528 ),
529 array( '%s', '%d' )
530 );
531 if ( 'products' === $digest['id_space'] ) {
532 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
533 }
534 if ( false === $deleted ) {
535 $label = $digest['label'];
536 throw new RuntimeException( 'delete stored ' . $label . 'digest failed: ' . $wpdb->last_error );
537 }
538 }
539 );
540 }
541
542 /**
543 * One statement: the digest is computed in SQL from the raw row and
544 * upserted in the same statement — PHP never materializes the value.
545 * No-op for rows outside the live predicate (the delete hook owns those).
546 * @deprecated Use record_post_saved().
547 */
548 public function upsert_digest( int $post_id ): void {
549 $this->upsert_for( 'products', $post_id );
550 }
551
552 /**
553 * Customer analogue of {@see upsert_digest} (ADR 0015, Leg-3 phase 7):
554 * compute and store one WordPress user's customer digest in a single
555 * INSERT…SELECT. Only the delete hook removes it.
556 * @deprecated Use record_customer_saved().
557 */
558 public function upsert_customer_digest( int $user_id ): void {
559 $this->upsert_for( 'customers', $user_id );
560 }
561
562 /** Compute and store one row using its id-space's canonical SELECT and retry policy. */
563 private function upsert_for( string $collection, int $id ): void {
564 global $wpdb;
565 $digest = Collections::row( $collection )['digest'];
566 $label = $digest['label'];
567 $started = microtime( true );
568 $this->index->raise_group_concat_max_len();
569 $this->query_with_retry(
570 $wpdb->prepare(
571 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
572 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
573 . ' FROM (' . $this->index->{$digest['select']}( $digest['id_column'] . ' = %d' ) . ') t'
574 . ' ON DUPLICATE KEY UPDATE digest = VALUES(digest), updated_gmt = VALUES(updated_gmt)',
575 $id
576 ),
577 'upsert stored ' . $label . 'digest failed: ',
578 $started
579 );
580 }
581
582 /**
583 * MySQL/MariaDB error numbers a second attempt can clear: 1020 ER_CHECKREAD
584 * ("Record has changed since last read"), 1205 ER_LOCK_WAIT_TIMEOUT, 1213
585 * ER_LOCK_DEADLOCK. Two requests upserting the same digest row race on
586 * the `INSERT … ON DUPLICATE KEY UPDATE`; the retry reads the updated row.
587 */
588 private const TRANSIENT_CONTENTION_ERRNOS = array( 1020, 1205, 1213 );
589
590 /**
591 * Message fallback for the same three errors, used only when the driver's
592 * error number is unavailable (a wpdb without a live mysqli handle).
593 */
594 private const TRANSIENT_CONTENTION_MESSAGES = array(
595 'Record has changed since last read',
596 'Lock wait timeout',
597 'Deadlock found',
598 );
599
600 /** Retry a contended upsert once, including both attempts in the hook timing. */
601 private function query_with_retry( string $sql, string $error_message, float $started ): void {
602 global $wpdb;
603 $result = $wpdb->query( $sql );
604 if ( false === $result && $this->is_transient_contention( $wpdb ) ) {
605 $result = $wpdb->query( $sql );
606 }
607 self::$request_write_ms += ( microtime( true ) - $started ) * 1000;
608 if ( false === $result ) {
609 throw new RuntimeException( $error_message . $wpdb->last_error );
610 }
611 }
612
613 /**
614 * The error number is authoritative: server messages are localised
615 * (`lc_messages`), so the English text is only a fallback for a handle-less
616 * wpdb. `$wpdb->dbh` is reachable through wpdb's magic getter.
617 */
618 private function is_transient_contention( \wpdb $wpdb ): bool {
619 $dbh = $wpdb->__get( 'dbh' );
620 if ( $dbh instanceof \mysqli ) {
621 $errno = mysqli_errno( $dbh ); // phpcs:ignore WordPress.DB.RestrictedFunctions -- reads the driver's last error number; no query is issued.
622 if ( 0 !== $errno ) {
623 return in_array( $errno, self::TRANSIENT_CONTENTION_ERRNOS, true );
624 }
625 }
626 foreach ( self::TRANSIENT_CONTENTION_MESSAGES as $message ) {
627 if ( false !== strpos( $wpdb->last_error, $message ) ) {
628 return true;
629 }
630 }
631 return false;
632 }
633
634 /**
635 * Backfill/repair: prune orphans, then digest every live row in one
636 * INSERT…SELECT pass. Pre-existing catalogs (the 10k seed) become fully
637 * digestable in one call; measured timing is returned so the lab can
638 * report the backfill price.
639 *
640 * @param bool $products_only Whether to stop after rebuilding product digests.
641 */
642 public function rebuild( bool $products_only = false ): array {
643 global $wpdb;
644 $this->index->raise_group_concat_max_len();
645 $started = microtime( true );
646
647 $orphans_deleted = $wpdb->query(
648 'DELETE FROM ' . $this->table_name()
649 . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
650 . ' AND NOT ' . $this->index->live_row_exists_sql( 'object_id' )
651 );
652 if ( false === $orphans_deleted ) {
653 throw new RuntimeException( 'prune orphan stored digests failed: ' . $wpdb->last_error );
654 }
655
656 // Affected-rows semantics of ON DUPLICATE KEY: 1 per insert, 2 per
657 // update, 0 per already-matching row — reported raw as "writes".
658 // updated_gmt is assigned FIRST and only when the digest actually
659 // changed (assignments evaluate left-to-right, so the IF must read
660 // the pre-update digest before the digest assignment overwrites it).
661 // Otherwise a repeated rebuild rewrites UTC_TIMESTAMP() into every
662 // row, counts the whole table as writes, and inflates the
663 // hash-checksum baseline cost (codex review).
664 $writes = $wpdb->query(
665 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
666 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
667 . ' FROM (' . $this->index->row_digest_select_sql() . ') t'
668 . ' ON DUPLICATE KEY UPDATE'
669 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
670 . ' digest = VALUES(digest)'
671 );
672 if ( false === $writes ) {
673 throw new RuntimeException( 'rebuild stored digests failed: ' . $wpdb->last_error );
674 }
675 update_option( Digest_Index::FORMULA_FP_OPTION, Digest_Index::digest_formula_fingerprint(), false );
676
677 if ( $products_only ) {
678 $stored_total = (int) $wpdb->get_var(
679 'SELECT COUNT(*) FROM ' . $this->table_name() . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
680 );
681
682 return array(
683 'writes' => (int) $writes,
684 'orphans_deleted' => (int) $orphans_deleted,
685 'stored_total' => $stored_total,
686 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
687 );
688 }
689
690 // Leg-3 phase 7 (ADR 0015): customers share the digest table via their own 'customer' rows —
691 // the same prune-orphans + INSERT…SELECT pass, over the customer predicate + id-space. A stored
692 // customer whose user vanished or lost the customer role is an orphan (a role removal never fires
693 // before_delete_post, so the rebuild is the backstop that reconciles it).
694 $customer_orphans = $wpdb->query(
695 'DELETE FROM ' . $this->table_name()
696 . " WHERE object_type = 'customer'"
697 . ' AND NOT ' . $this->index->customer_live_row_exists_sql( 'object_id' )
698 );
699 if ( false === $customer_orphans ) {
700 throw new RuntimeException( 'prune orphan customer digests failed: ' . $wpdb->last_error );
701 }
702 $customer_writes = $wpdb->query(
703 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
704 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
705 . ' FROM (' . $this->index->customer_digest_select_sql() . ') t'
706 . ' ON DUPLICATE KEY UPDATE'
707 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
708 . ' digest = VALUES(digest)'
709 );
710 if ( false === $customer_writes ) {
711 throw new RuntimeException( 'rebuild customer digests failed: ' . $wpdb->last_error );
712 }
713
714 // Leg-3 phase 7 (ADR 0015): orders share the digest table via their own 'order' rows (HPOS or CPT).
715 // Same prune-orphans + INSERT…SELECT pass; Digest_Index::order_digest_select_sql() emits the storage-correct SQL
716 // (the CPT path GROUP BYs, the HPOS path does not — both valid as an INSERT…SELECT source).
717 $order_orphans = $wpdb->query(
718 'DELETE FROM ' . $this->table_name()
719 . " WHERE object_type = 'order'"
720 . ' AND NOT ' . $this->index->order_live_row_exists_sql( 'object_id' )
721 );
722 if ( false === $order_orphans ) {
723 throw new RuntimeException( 'prune orphan order digests failed: ' . $wpdb->last_error );
724 }
725 $order_writes = $wpdb->query(
726 'INSERT INTO ' . $this->table_name() . ' (object_type, object_id, digest, updated_gmt)'
727 . ' SELECT t.object_type, t.id, t.crc, UTC_TIMESTAMP()'
728 . ' FROM (' . $this->index->order_digest_select_sql() . ') t'
729 . ' ON DUPLICATE KEY UPDATE'
730 . ' updated_gmt = IF(digest <=> VALUES(digest), updated_gmt, VALUES(updated_gmt)),'
731 . ' digest = VALUES(digest)'
732 );
733 if ( false === $order_writes ) {
734 throw new RuntimeException( 'rebuild order digests failed: ' . $wpdb->last_error );
735 }
736
737 $stored_total = (int) $wpdb->get_var(
738 'SELECT COUNT(*) FROM ' . $this->table_name() . ' WHERE object_type IN ' . self::OBJECT_TYPES_SQL
739 );
740 $customer_stored_total = (int) $wpdb->get_var(
741 'SELECT COUNT(*) FROM ' . $this->table_name() . " WHERE object_type = 'customer'"
742 );
743 $order_stored_total = (int) $wpdb->get_var(
744 'SELECT COUNT(*) FROM ' . $this->table_name() . " WHERE object_type = 'order'"
745 );
746
747 return array(
748 'writes' => (int) $writes + (int) $customer_writes + (int) $order_writes,
749 'orphans_deleted' => (int) $orphans_deleted + (int) $customer_orphans + (int) $order_orphans,
750 'stored_total' => $stored_total + $customer_stored_total + $order_stored_total,
751 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
752 );
753 }
754 }
755