get_var( $wpdb->prepare( "SELECT COALESCE(MAX(%i),0) FROM %i WHERE %i='shop_order' AND %i NOT IN ('trash','auto-draft')", $id, $orders, $type, $status ) ); update_option( self::REFRESH, array( 'epoch'=>wp_generate_uuid4(), 'since'=>$since, 'phase'=>$since !== '' ? 'touched' : 'orders', 'cursor'=>0, 'ceiling'=>$ceiling, 'table'=>0 ), false ); self::nudge( 1 ); } /** Whether a background refresh is under way. */ public static function refreshing(): bool { return (bool) self::refresh_state(); } /** * Books the refresh's next job unless one already waits (also from the screens' polls, should a job have been lost). A * scheduler that cannot book it now (an activation before Action Scheduler is up) never stops the caller: the next poll * books it. */ public static function nudge( int $delay = 5 ): void { if ( ! self::refresh_state() ) { return; } try { Store::later( self::REFRESH_HOOK, array(), self::REFRESH_GROUP, $delay ); } catch ( \Throwable $e ) { return; } } /** One background job: batches for up to REFRESH_SECONDS, then the next job, until the refresh is done. */ public static function refresh_step(): void { $until = microtime( true ) + self::REFRESH_SECONDS; $next = 1; while ( true ) { $result = self::refresh_batch(); if ( $result === 'done' ) { return; } if ( $result === 'busy' ) { $next = 30; break; } if ( microtime( true ) >= $until ) { break; } } self::nudge( $next ); } /** * One batch of the refresh under the rebuild's named lock: 'more', 'done' (finished, stopped or replaced by a blocking * pass) or 'busy' (another request holds the lock). Each order is read while its index row is locked, as in the blocking * pass; an index write that fails starts the blocking pass (restart()), as any failed index write does. The state is * written only while it is still this refresh's (its epoch): a refresh that was replaced or stopped meanwhile keeps nothing. */ public static function refresh_batch(): string { global $wpdb; $lock = self::lock_name(); if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) !== 1 ) { return 'busy'; } try { $state = self::refresh_state(); if ( ! $state ) { return 'done'; } if ( self::stored( self::READY ) !== '1' ) { delete_option( self::REFRESH ); return 'done'; } self::$reading = true; self::$lock_wait = false; try { if ( $state['phase'] === 'clean' ) { self::clean( $state ); } else { $touched = $state['phase'] === 'touched'; $ids = $touched ? self::touched( (string) $state['since'], (int) $state['cursor'] ) : self::live( (int) $state['cursor'], (int) $state['ceiling'] ); foreach ( $ids as $one ) { self::read( $one ); $state['cursor'] = $one; } if ( count( $ids ) < self::BATCH ) { $state['phase'] = $touched ? 'orders' : 'clean'; $state['cursor'] = 0; $state['table'] = 0; } } } catch ( \Throwable $e ) { // Another request held a row past the database's lock wait (a long money action on the same order) or a deadlock: // this batch waits, and the next one starts again from the stored cursor (review R4). Any other failed index // write rebuilds the index, as it does elsewhere. if ( self::$lock_wait ) { return 'busy'; } self::restart(); return 'done'; } finally { self::$reading = false; } if ( ( self::refresh_state()['epoch'] ?? '' ) !== $state['epoch'] ) { return self::refresh_state() ? 'more' : 'done'; } if ( $state['phase'] === 'done' ) { delete_option( self::REFRESH ); return 'done'; } update_option( self::REFRESH, $state, false ); return 'more'; } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } /** The refresh's state as stored now, or an empty array. */ private static function refresh_state(): array { self::forget( self::REFRESH ); $state = get_option( self::REFRESH ); return is_array( $state ) && isset( $state['epoch'], $state['phase'] ) ? $state : array(); } /** Reads one order into its index row now (the row locked meanwhile), as a pass does; false when it could not. */ public static function read_now( int $id ): bool { try { self::read( $id ); return true; } catch ( \Throwable $e ) { return false; } } /** Whether the order index is being written by a recovery step (its own failures are reported by the step). */ public static function reading(): bool { return self::$reading; } /** The named lock of one store's rebuild (per database and table prefix, as Finance's money lock). */ public static function lock_name(): string { global $wpdb; return 'ss-order-index-' . md5( DB_NAME . $wpdb->prefix ); } public static function sync(): array { global $wpdb; $lock = self::lock_name(); if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) !== 1 ) { // Another request is taking a step (two tabs, two people): this one is told so and asks again shortly. $state = self::cursor(); return array( 'ready'=>false, 'busy'=>true, 'done'=>(int) ( $state['done'] ?? 0 ), 'total'=>(int) ( $state['total'] ?? 0 ) ); } try { return self::step(); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } private static function step(): array { if ( self::stored( self::READY ) === '1' ) { return array( 'ready'=>true ); } // Finished by the request that held the lock. $epoch = self::stored( self::EPOCH ); if ( $epoch === '' ) { $epoch = wp_generate_uuid4(); self::put( self::EPOCH, $epoch ); } // Made not ready before 0.62.13. $state = self::cursor(); if ( ( $state['epoch'] ?? null ) !== $epoch ) { $state = self::begin( $epoch ); } self::$reading = true; try { if ( $state['phase'] === 'clean' ) { self::clean( $state ); } else { $saved = $state['phase'] === 'saved'; $ids = ! $saved && $state['since'] === '' ? self::live( $state['cursor'], $state['ceiling'] ) : self::touched( $saved ? $state['started'] : $state['since'], $state['cursor'] ); foreach ( $ids as $id ) { self::read( $id ); $state['cursor'] = $id; if ( ! $saved ) { ++$state['done']; } } // Then the orders saved while this pass ran (one may have been read just before its save), then stale rows. if ( count( $ids ) < self::BATCH ) { $state['phase'] = $saved ? 'clean' : 'saved'; $state['cursor'] = 0; } } } finally { self::$reading = false; } $progress = array( 'ready'=>false, 'done'=>min( $state['done'], $state['total'] ), 'total'=>$state['total'] ); // Restarted while this step ran (an index write failed elsewhere): nothing of this pass is kept. if ( self::stored( self::EPOCH ) !== $epoch ) { return $progress; } if ( $state['phase'] !== 'done' ) { update_option( self::CURSOR, $state, false ); return $progress; } self::put( self::READY, '1' ); // A restart between the check above and this write takes the ready flag back at once. if ( self::stored( self::EPOCH ) !== $epoch ) { self::put( self::READY, '0' ); return $progress; } delete_option( self::CURSOR ); delete_option( 'storesuit_order_index_pending' ); return array( 'ready'=>true, 'done'=>$state['total'], 'total'=>$state['total'] ); } private static function begin( string $epoch ): array { global $wpdb; [ $orders, $id, $type, $status ] = self::native(); $since = (string) ( explode( '|', $epoch, 2 )[1] ?? '' ); $state = array( 'epoch'=>$epoch, 'since'=>$since, 'phase'=>'orders', 'cursor'=>0, 'ceiling'=>0, 'total'=>0, 'done'=>0, 'started'=>gmdate( 'Y-m-d H:i:s' ), 'table'=>0 ); if ( $since === '' ) { // A fixed ceiling keeps every step bounded; orders created after it are written by the save hooks. $row = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE(MAX(%i),0) ceiling,COUNT(*) total FROM %i WHERE %i='shop_order' AND %i NOT IN ('trash','auto-draft')", $id, $orders, $type, $status ), ARRAY_A ); if ( $wpdb->last_error !== '' || ! is_array( $row ) ) { throw self::failure(); } $state['ceiling'] = (int) $row['ceiling']; $state['total'] = (int) $row['total']; } else { $total = self::touched_read( $since, 0, true ); if ( $wpdb->last_error !== '' || $total === null ) { throw self::failure(); } $state['total'] = (int) $total; } return $state; } /** Reads one order into its index row and facts while the row is locked (review I1, scenario 3: no lost update). */ private static function read( int $id ): void { global $wpdb; $own = ! Store::in_transaction(); $ok = false; // Never inside a money transaction: START TRANSACTION would commit it. if ( $own && false === $wpdb->query( 'START TRANSACTION' ) ) { throw self::failure(); } try { $wpdb->get_var( $wpdb->prepare( 'SELECT order_id FROM %i WHERE order_id=%d FOR UPDATE', OrderIndex::table(), $id ) ); $ok = $wpdb->last_error === '' && OrderIndex::changed( $id ); } finally { if ( ! $ok ) { self::note_lock_wait(); } if ( $own && ( false === ( $ok ? $wpdb->query( 'COMMIT' ) : $wpdb->query( 'ROLLBACK' ) ) ) ) { $ok = false; } } if ( ! $ok ) { throw self::failure(); } } /** * Remembers whether the statement that just failed met another request's rows: it waited too long for a row (1205), was a * deadlock (1213), found a record changed since it was read (1020) or a full lock table (1206) (Store::ENDED). */ private static function note_lock_wait(): void { global $wpdb; $dbh = isset( $wpdb->dbh ) ? $wpdb->dbh : null; if ( $dbh instanceof \mysqli && in_array( (int) $dbh->errno, Store::ENDED, true ) ) { self::$lock_wait = true; } } /** Rows whose order was deleted, trashed or left as an auto-draft while StoreRuler was off, in bounded windows. */ private static function clean( array &$state ): void { global $wpdb; [ $orders, $id, $type, $status ] = self::native(); $tables = array( OrderIndex::table() ); if ( get_option( 'storesuit_dashboard_facts_schema' ) === '1' ) { $tables[] = DashboardFacts::table(); } $deadline = microtime( true ) + 1.5; while ( $state['table'] < count( $tables ) && microtime( true ) < $deadline ) { $table = $tables[ $state['table'] ]; $high = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(order_id) FROM (SELECT order_id FROM %i WHERE order_id>%d ORDER BY order_id LIMIT %d) w', $table, $state['cursor'], self::WINDOW ) ); if ( $wpdb->last_error !== '' ) { throw self::failure(); } if ( $high === null ) { ++$state['table']; $state['cursor'] = 0; continue; } // A plain read: WooCommerce's order rows are never locked by this check. $stale = $wpdb->get_col( $wpdb->prepare( "SELECT c.order_id FROM %i c LEFT JOIN %i o ON o.%i=c.order_id AND o.%i='shop_order' AND o.%i NOT IN ('trash','auto-draft') WHERE c.order_id>%d AND c.order_id<=%d AND o.%i IS NULL", $table, $orders, $id, $type, $status, $state['cursor'], (int) $high, $id ) ); if ( $wpdb->last_error !== '' || ! is_array( $stale ) ) { throw self::failure(); } if ( $stale ) { self::drop( array_map( 'intval', $stale ), $tables ); } $state['cursor'] = (int) $high; } if ( $state['table'] >= count( $tables ) ) { $state['phase'] = 'done'; } } /** Removes the rows, checked again while they are locked: an order restored meanwhile keeps its row. */ private static function drop( array $ids, array $tables ): void { global $wpdb; [ $orders, $id, $type, $status ] = self::native(); $own = ! Store::in_transaction(); $ok = false; if ( $own && false === $wpdb->query( 'START TRANSACTION' ) ) { throw self::failure(); } try { $wpdb->get_col( $wpdb->prepare( 'SELECT order_id FROM %i WHERE order_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') FOR UPDATE', array_merge( array( OrderIndex::table() ), $ids ) ) ); $live = $wpdb->get_col( $wpdb->prepare( 'SELECT %i FROM %i WHERE %i IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND %i='shop_order' AND %i NOT IN ('trash','auto-draft')", array_merge( array( $id, $orders, $id ), $ids, array( $type, $status ) ) ) ); if ( $wpdb->last_error === '' ) { $gone = array_values( array_diff( $ids, array_map( 'intval', (array) $live ) ) ); $ok = true; if ( $gone ) { foreach ( $tables as $table ) { if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE order_id IN (' . implode( ',', array_fill( 0, count( $gone ), '%d' ) ) . ')', array_merge( array( $table ), $gone ) ) ) ) { $ok = false; break; } } } } } finally { if ( ! $ok ) { self::note_lock_wait(); } if ( $own && ( false === ( $ok ? $wpdb->query( 'COMMIT' ) : $wpdb->query( 'ROLLBACK' ) ) ) ) { $ok = false; } } if ( ! $ok ) { throw self::failure(); } } /** The next batch of indexable orders up to the ceiling. */ private static function live( int $after, int $ceiling ): array { global $wpdb; [ $orders, $id, $type, $status ] = self::native(); $ids = $wpdb->get_col( $wpdb->prepare( "SELECT %i FROM %i WHERE %i='shop_order' AND %i NOT IN ('trash','auto-draft') AND %i>%d AND %i<=%d ORDER BY %i LIMIT %d", $id, $orders, $type, $status, $id, $after, $id, $ceiling, $id, self::BATCH ) ); if ( $wpdb->last_error !== '' || ! is_array( $ids ) ) { throw self::failure(); } return array_map( 'intval', $ids ); } /** The next batch of orders saved, or refunded, since a moment; trashed ones and auto-drafts too, so their rows go. */ private static function touched( string $since, int $after ): array { global $wpdb; $ids = self::touched_read( $since, $after, false ); if ( $wpdb->last_error !== '' || ! is_array( $ids ) ) { throw self::failure(); } return array_map( 'intval', $ids ); } /** * Orders whose WooCommerce record changed since a moment, a minute early for clock and commit skew. HPOS updates its * date on every save; the posts table on status, date and admin-screen saves. A refund counts for its order. * With $count, how many there are (NULL when the count did not run); otherwise the next batch of ids after $after. */ private static function touched_read( string $since, int $after, bool $count ) { global $wpdb; [ $orders, $id, $type, , $modified, $parent ] = self::native(); $at = gmdate( 'Y-m-d H:i:s', (int) strtotime( $since . ' UTC' ) - MINUTE_IN_SECONDS ); return $count ? $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM (SELECT %i id FROM %i WHERE %i='shop_order' AND %i>=%s AND %i>%d UNION SELECT %i FROM %i WHERE %i='shop_order_refund' AND %i>=%s AND %i>%d) x", $id, $orders, $type, $modified, $at, $id, $after, $parent, $orders, $type, $modified, $at, $parent, $after ) ) : $wpdb->get_col( $wpdb->prepare( "SELECT x.id FROM (SELECT %i id FROM %i WHERE %i='shop_order' AND %i>=%s AND %i>%d UNION SELECT %i FROM %i WHERE %i='shop_order_refund' AND %i>=%s AND %i>%d) x ORDER BY x.id LIMIT %d", $id, $orders, $type, $modified, $at, $id, $after, $parent, $orders, $type, $modified, $at, $parent, $after, self::BATCH ) ); } /** * The authoritative order table and its columns: table, id, type, status, modified (GMT), parent. The queries pass them * as %i identifiers. An order that belongs in the index has the type shop_order and is neither trashed nor an abandoned * "Add order" screen (an auto-draft; review M3). */ private static function native(): array { global $wpdb; return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ? array( $wpdb->prefix . 'wc_orders', 'id', 'type', 'status', 'date_updated_gmt', 'parent_order_id' ) : array( $wpdb->posts, 'ID', 'post_type', 'post_status', 'post_modified_gmt', 'post_parent' ); } private static function failure(): \RuntimeException { return new \RuntimeException( I18n::t( 'The order search index could not be updated. Sales records are safe.' ) ); } /** The pass state as stored now, not as this request may have cached it. */ private static function cursor(): array { self::forget( self::CURSOR ); $state = get_option( self::CURSOR ); return is_array( $state ) ? $state : array(); } /** Read from the database: another request may have changed the value after this one cached it. */ private static function stored( string $name ): string { global $wpdb; return (string) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, $name ) ); } /** Written to the database whatever this request cached (update_option skips a value it believes unchanged). */ private static function put( string $name, string $value ): void { global $wpdb; $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,%s,'no') ON DUPLICATE KEY UPDATE option_value=%s", $wpdb->options, $name, $value, $value ) ); self::forget( $name ); } private static function forget( string $name ): void { wp_cache_delete( $name, 'options' ); $all = wp_cache_get( 'alloptions', 'options' ); if ( is_array( $all ) && array_key_exists( $name, $all ) ) { wp_cache_delete( 'alloptions', 'options' ); } $none = wp_cache_get( 'notoptions', 'options' ); if ( is_array( $none ) && isset( $none[ $name ] ) ) { unset( $none[ $name ] ); wp_cache_set( 'notoptions', $none, 'options' ); } } }