= self::LIMIT ) { $state['lost'] = $state['lost'] ?: time(); continue; } // A change seen again while waiting gets a new number, so a replay that started earlier keeps it. $state['items'][ $entry ] = ++$state['seq']; } return true; } ); } catch ( \Throwable $e ) { return false; } try { self::schedule( self::DELAY ); } catch ( \Throwable $e ) { /* The owner's next background run drains the queue too. */ } return true; } /** WP-Cron wake. Another writer may still be busy: then its run ends with a drain, and this looks again shortly. */ public static function wake(): void { if ( ! Reviewed_Store::housekeeping( array( self::class, 'drain' ) ) ) { self::schedule( self::DELAY ); } } /** After activation: changes recorded before a deactivation are replayed. */ public static function resume(): void { try { if ( self::read()[1]['items'] ) { self::schedule( 0 ); } } catch ( \Throwable $e ) { self::schedule( 0 ); } } /** Owner only. Replays up to STEP recorded changes in the order they were seen; never throws once the lock is held. */ public static function drain(): void { $held = Reviewed_Store::enter(); try { // Never mix unrelated tracking into an approved execution; the next run or wake replays it. if ( '' !== Reviewed_Store::execution_marker() ) { return; } self::replay_queue(); } finally { Reviewed_Store::leave( $held ); } } /** * How many recorded changes wait. * * @return int The count (0 when the queue cannot be read). */ public static function pending(): int { try { return count( self::read()[1]['items'] ); } catch ( \Throwable $e ) { return 0; } } /** * Replay the recorded changes through Image Manager's own observers; an unreadable queue is started again and * reported. */ private static function replay_queue(): void { try { $state = self::read()[1]; } catch ( \Throwable $e ) { // An unreadable queue lost its changes: start a new one and say so. self::reset(); Plugin::tracking_failed(); return; } // More changes than the queue holds were seen while busy: those were not tracked, which is a real tracking failure. if ( ! empty( $state['lost'] ) && Plugin::tracking_failed() ) { try { self::change( static function ( &$s ) { $s['lost'] = 0; return true; } ); } catch ( \Throwable $e ) { /* Reported again next time. */ } } $items = $state['items']; if ( ! $items ) { return; } asort( $items ); $done = array(); $busy = false; foreach ( array_slice( $items, 0, self::STEP, true ) as $entry => $seq ) { try { self::replay( (string) $entry ); } catch ( \Throwable $e ) { if ( Reviewed_Store::busy( $e ) ) { $busy = true; break; } // A held lease: keep this change for the next drain. Plugin::tracking_failed(); } $done[ $entry ] = $seq; } try { self::change( static function ( &$s ) use ( $done ) { foreach ( $done as $entry => $seq ) { if ( ( $s['items'][ $entry ] ?? null ) === $seq ) { unset( $s['items'][ $entry ] ); } } return true; } ); } catch ( \Throwable $e ) { /* Replayed again next time; every replay is safe to repeat. */ } if ( $busy || count( $items ) > count( $done ) ) { self::schedule( $busy ? self::DELAY : 0 ); } } /** * One recorded change, through the same Image Manager code the observer runs. * * @param string $entry One recorded change (type:ID). */ private static function replay( string $entry ): void { $parts = explode( ':', $entry, 3 ); $id = (int) ( $parts[1] ?? 0 ); if ( $id <= 0 ) { return; } switch ( $parts[0] ) { case 'attachment_saved': Plugin::attachment_saved( $id ); break; case 'attachment_deleted': Plugin::attachment_deleted( $id ); break; case 'meta_changed': if ( in_array( $parts[2] ?? '', Plugin::IMAGE_META, true ) ) { Plugin::meta_changed( 0, $id, $parts[2], null ); } break; case 'post_saved': Plugin::post_saved( $id ); break; case 'source_retired': Plugin::source_retired( $id ); break; case 'comment_saved': Plugin::comment_saved( $id ); break; case 'comment_deleted': Plugin::comment_deleted( $id ); break; case 'discovery': Auto_Discovery::recheck( $id ); break; } } /** * Schedule the wake once. * * @param int $delay Seconds from now. */ private static function schedule( int $delay ): void { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + $delay, self::HOOK ); } } /** * An empty queue. * * @return array Items, sequence, lost count and revision. */ private static function empty_state(): array { return array( 'items' => array(), 'seq' => 0, 'lost' => 0, 'revision' => '', ); } /** * The queue as stored (read past every cache). * * @return array The stored text (null when there is none) and the queue. * @throws \RuntimeException With invalid_tracking_queue when the stored queue cannot be read. */ private static function read(): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read/CAS the authoritative tracking queue; stale option/object-cache data cannot arbitrate concurrent saves. $raw = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, self::OPTION ) ); $state = is_string( $raw ) ? json_decode( $raw, true ) : null; if ( null !== $raw && ( ! is_array( $state ) || ! isset( $state['items'] ) || ! is_array( $state['items'] ) ) ) { throw new \RuntimeException( 'invalid_tracking_queue' ); } return array( $raw, array_merge( self::empty_state(), $state ?: array() ) ); } /** * Compare-and-swap the bounded, non-autoloaded queue. Concurrent saves cannot overwrite each other. * * @param callable $edit Changes the queue in place. * @throws \RuntimeException With tracking_queue_busy when the queue cannot be written in eight attempts. */ private static function change( callable $edit ): void { global $wpdb; for ( $i = 0; $i < 8; ++$i ) { list( $raw, $state ) = self::read(); if ( null === $raw ) { // INSERT IGNORE, not add_option(): add_option() overwrites a row another request created a moment earlier. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Create the CAS row once; the next read arbitrates. Reviewed_Store::database_query( $wpdb->prepare( 'INSERT IGNORE INTO %i (option_name,option_value,autoload) VALUES (%s,%s,%s)', $wpdb->options, self::OPTION, wp_json_encode( self::empty_state() ), 'no' ) ); continue; } $edit( $state ); $state['revision'] = bin2hex( random_bytes( 16 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Read/CAS the authoritative tracking queue; stale option/object-cache data cannot arbitrate concurrent saves. $saved = Reviewed_Store::database_query( $wpdb->prepare( 'UPDATE %i SET option_value=%s WHERE option_name=%s AND option_value=%s', $wpdb->options, wp_json_encode( $state ), self::OPTION, $raw ) ); if ( 1 === $saved ) { wp_cache_delete( self::OPTION, 'options' ); return; } if ( false === $saved ) { break; } } throw new \RuntimeException( 'tracking_queue_busy' ); } /** * Replace an unreadable queue with an empty one. */ private static function reset(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Replace an unreadable tracking queue row under the owner lock. Reviewed_Store::database_query( $wpdb->prepare( 'UPDATE %i SET option_value=%s WHERE option_name=%s', $wpdb->options, wp_json_encode( self::empty_state() ), self::OPTION ) ); wp_cache_delete( self::OPTION, 'options' ); } }