Making WordPress.org

Changeset 14879


Ignore:
Timestamp:
05/14/2026 08:34:42 AM (3 months ago)
Author:
dd32
Message:

Plugin Directory: Avoid double importing of plugin releases from trunk/tags.

This adds an intentional 15 minute delay to trunk-commit processing, such that authors have time to perform the svn cp process to tag the release, which then triggers the Plugin Directory release process.

Tag commits are processed immediately, plugins released from trunk are still processed (only after that 15minute grace) which accounts for a small percentage of plugins in the last week.

This should help with processing times overall, as there'll be less duplicate jobs attempting to import plugins (that were just imported) and less jobs queued for plugin scans.

Props dd32.
Closes https://github.com/WordPress/wordpress.org/pull/631.

Location:
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory
Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php

    r14352 r14879  
    4141                $last_rev_processed++;
    4242
    43                 echo "Processing changes from $last_rev_processed to $head_rev..\n";
     43                if ( defined( 'STDERR' ) ) {
     44                        fwrite( STDERR, "svn-watch: processing changes from r$last_rev_processed to r$head_rev\n" );
     45                }
    4446
    4547                $plugins_to_process = $this->get_plugin_changes_between( $last_rev_processed, $head_rev );
     
    4850                        if ( $plugin_data['assets_touched'] && ! in_array( 'trunk', $plugin_data['tags_touched'] ) ) {
    4951                                $plugin_data['tags_touched'][] = 'trunk';
     52                        }
     53
     54                        if ( defined( 'STDERR' ) ) {
     55                                $log_line = sprintf(
     56                                        "[%s] svn-watch: r%d-r%d tags=[%s]%s%s%s%s\n",
     57                                        $plugin_slug,
     58                                        min( $plugin_data['revisions'] ),
     59                                        max( $plugin_data['revisions'] ),
     60                                        implode( ',', $plugin_data['tags_touched'] ),
     61                                        $plugin_data['tags_deleted'] ? ' deleted=[' . implode( ',', $plugin_data['tags_deleted'] ) . ']' : '',
     62                                        $plugin_data['readme_touched'] ? ' readme' : '',
     63                                        $plugin_data['code_touched']   ? ' code' : '',
     64                                        $plugin_data['assets_touched'] ? ' assets' : ''
     65                                );
     66                                fwrite( STDERR, $log_line );
    5067                        }
    5168
     
    156173                                }
    157174
    158                                 // This will have false-positives for when a readme in a subdirectory is hit, but this is only for optimizations.
    159                                 if ( in_array( strtolower( basename( $path ) ), array( 'readme.txt', 'readme.md' ) ) ) {
     175                                // Only count the readme at the root of /trunk or a tag — a readme.txt in a subdirectory is just bundled documentation.
     176                                if ( preg_match( '!/(trunk|tags/[^/]+)/readme\.(txt|md)$!i', $path ) ) {
    160177                                        $plugin['readme_touched'] = true;
    161178                                }
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php

    r14872 r14879  
    8888                wp_cache_delete( 'jobs', 'cavalcade-jobs' );
    8989
    90                 $crons = _get_cron_array();
    91                 if ( empty( $crons ) ) {
     90                $timestamps = array();
     91
     92                foreach ( _get_cron_array() as $timestamp => $cron ) {
     93                        foreach ( $cron[ $hook ] ?? [] as $cron_item ) {
     94                                if ( 'waiting' === ( $cron_item['_job']->status ?? '' ) ) {
     95                                        $timestamps[] = $timestamp;
     96                                        break;
     97                                }
     98                        }
     99                }
     100
     101                if ( ! $timestamps ) {
    92102                        return false;
    93103                }
    94104
    95                 $timestamps = array();
    96 
    97                 foreach ( $crons as $timestamp => $cron ) {
    98                         if ( isset( $cron[ $hook ] ) ) {
    99                                 foreach ( $cron[ $hook ] as $key => $cron_item ) {
    100                                         // Cavalcade should present this field, if not, bail.
    101                                         if ( empty( $cron_item['_job'] ) ) {
    102                                                 continue;
    103                                         }
    104 
    105                                         if ( 'waiting' === $cron_item['_job']->status ) {
    106                                                 $timestamps[] = $timestamp;
    107                                                 break;
    108                                         }
    109                                 }
    110                         }
    111                 }
    112 
    113                 if ( empty( $timestamps ) ) {
    114                         return false;
    115                 }
    116 
    117                 if ( 'last' == $when ) {
    118                         return max( $timestamps );
    119                 } else {
    120                         return min( $timestamps );
    121                 }
     105                return 'last' === $when ? max( $timestamps ) : min( $timestamps );
     106        }
     107
     108        /**
     109         * Determines whether any job for a given hook is currently running.
     110         *
     111         * @param string $hook The hook to check.
     112         * @return bool True if a job for this hook is currently running.
     113         */
     114        public static function is_event_running( $hook ) {
     115                // Flush the Cavalcade jobs cache, we need fresh data from the database.
     116                wp_cache_delete( 'jobs', 'cavalcade-jobs' );
     117
     118                foreach ( _get_cron_array() as $cron ) {
     119                        foreach ( $cron[ $hook ] ?? [] as $cron_item ) {
     120                                if ( 'running' === ( $cron_item['_job']->status ?? '' ) ) {
     121                                        return true;
     122                                }
     123                        }
     124                }
     125
     126                return false;
    122127        }
    123128
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php

    r14271 r14879  
    44use Exception;
    55use WordPressdotorg\Plugin_Directory\CLI;
     6use WordPressdotorg\Plugin_Directory\Plugin_Directory;
     7use WordPressdotorg\Plugin_Directory\Readme\Parser as Readme_Parser;
     8use WordPressdotorg\Plugin_Directory\Tools\SVN;
    69
    710/**
     
    1215class Plugin_Import {
    1316
     17        /**
     18         * Queue an import job for a plugin, merging into any pending future event for the
     19         * same plugin so a single import covers all the SVN changes seen so far.
     20         *
     21         * @param string $plugin_slug The plugin slug.
     22         * @param array  $plugin_data Data about the SVN change (tags_touched, revisions, etc).
     23         */
    1424        public static function queue( $plugin_slug, $plugin_data ) {
    15                 // To avoid a situation where two imports run concurrently, if one is already scheduled, run it 1hr later (We'll trigger it after the current one finishes).
    16                 $when_to_run = time() + 5;
    17                 if ( $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ) ) {
    18                         $when_to_run = $next_scheduled + HOUR_IN_SECONDS;
     25                $new_args = array_merge( array( 'plugin' => $plugin_slug ), $plugin_data );
     26
     27                /*
     28                 * If there's already a future-scheduled import for this plugin and nothing
     29                 * is currently running, fold the new request into it. This handles two
     30                 * cases under one rule:
     31                 *
     32                 *  - A bulk batch re-index queued an event far in the future; a fresh
     33                 *    commit should pull that forward to the usual import time.
     34                 *  - The 15-minute trunk grace window (see queue_run_time()): an author
     35                 *    commits to /trunk first and then `svn cp trunk tags/X.Y` a moment
     36                 *    later. The first commit's event is delayed; the second merges into
     37                 *    it so a single import publishes from the tag, not from a trunk
     38                 *    fallback that the tag commit then has to overwrite.
     39                 *
     40                 * Skip the merge for events due in the next 5 seconds: a Cavalcade
     41                 * runner could claim the row between our status check and the row write,
     42                 * and the resulting save would clobber the runner's `status='running'`
     43                 * transition. 5s assumes the status read at the top of this function
     44                 * is still fresh by the time `update_scheduled_event` writes.
     45                 */
     46                $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'next' );
     47                if (
     48                        $next_scheduled &&
     49                        $next_scheduled > time() + 5 &&
     50                        ! Manager::is_event_running( "import_plugin:{$plugin_slug}" )
     51                ) {
     52                        $existing      = Manager::get_scheduled_events( "import_plugin:{$plugin_slug}", $next_scheduled );
     53                        $existing_args = $existing[0]['args'][0] ?? array();
     54                        $merged_args   = self::merge_plugin_data( $existing_args, $new_args );
     55
     56                        list( $natural_time, $natural_reason ) = self::queue_run_time( $plugin_slug, $merged_args );
     57                        $nextrun                               = min( $next_scheduled, $natural_time );
     58
     59                        $updated = Manager::update_scheduled_event(
     60                                "import_plugin:{$plugin_slug}",
     61                                $next_scheduled,
     62                                array(
     63                                        'nextrun' => $nextrun,
     64                                        'args'    => array( $merged_args ),
     65                                )
     66                        );
     67
     68                        if ( $updated ) {
     69                                if ( defined( 'STDERR' ) ) {
     70                                        $log_line = sprintf(
     71                                                "[%s] queue: merged into pending event, next run %s (%s)\n",
     72                                                $plugin_slug,
     73                                                gmdate( 'Y-m-d H:i:s', $nextrun ),
     74                                                $natural_reason
     75                                        );
     76                                        fwrite( STDERR, $log_line );
     77                                }
     78
     79                                return;
     80                        }
     81                }
     82
     83                list( $when_to_run, $reason ) = self::queue_run_time( $plugin_slug, $new_args );
     84
     85                // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes).
     86                $last_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' );
     87                if ( $last_scheduled ) {
     88                        $when_to_run = $last_scheduled + HOUR_IN_SECONDS;
     89                        $reason      = 'concurrency push: 1hr after pending event';
     90                } elseif ( Manager::is_event_running( "import_plugin:{$plugin_slug}" ) ) {
     91                        $when_to_run = time() + HOUR_IN_SECONDS;
     92                        $reason      = 'concurrency push: 1hr (import in flight)';
    1993                }
    2094
     
    2296                        $when_to_run,
    2397                        "import_plugin:{$plugin_slug}",
    24                         array(
    25                                 array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ),
    26                         )
     98                        array( $new_args )
    2799                );
     100
     101                if ( defined( 'STDERR' ) ) {
     102                        $log_line = sprintf(
     103                                "[%s] queue: scheduled %s (%s)\n",
     104                                $plugin_slug,
     105                                gmdate( 'Y-m-d H:i:s', $when_to_run ),
     106                                $reason
     107                        );
     108                        fwrite( STDERR, $log_line );
     109                }
     110        }
     111
     112        /**
     113         * Decide when an import job should run, based on the SVN changes it covers.
     114         *
     115         * Authors that release from a tag commonly commit the version bump to /trunk
     116         * first and then `svn cp trunk tags/X.Y` a moment later. Running the import
     117         * immediately on the trunk commit publishes a trunk-fallback release that
     118         * the follow-up tag commit then re-publishes from the tag. To collapse the
     119         * two into one import, trunk-only updates are deferred by 15 minutes — that
     120         * gives the follow-up tag commit time to merge into the same job (see
     121         * queue()). Tag-touching changes (additions or deletions) run immediately.
     122         *
     123         * The grace window is bypassed when the only change is a `Stable Tag` flip
     124         * in /trunk/readme.txt pointing at a tag that already exists in /tags/:
     125         * there's no follow-up tag to wait for in that case.
     126         *
     127         * @param string $plugin_slug The plugin slug.
     128         * @param array  $args        The args for the import job (post-merge where applicable).
     129         * @return array { 0: int timestamp, 1: string reason } — when the event should run, and why.
     130         */
     131        protected static function queue_run_time( $plugin_slug, $args ) {
     132                if ( ! self::is_trunk_only_update( $args ) ) {
     133                        return array( time() + 5, 'tag activity' );
     134                }
     135
     136                // Only the SVN watcher passes `revisions` — for manual queues (wp-admin sync button, release-confirmation API, bin/import-plugin) there's no follow-up tag commit to wait for, so skip the grace window.
     137                if ( empty( $args['revisions'] ) ) {
     138                        return array( time() + 5, 'manual queue' );
     139                }
     140
     141                if ( ! empty( $args['readme_touched'] ) && self::trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) ) {
     142                        return array( time() + 5, 'release-by-readme-flip to existing tag' );
     143                }
     144
     145                return array( time() + 15 * MINUTE_IN_SECONDS, '15-minute trunk grace window for follow-up tag' );
     146        }
     147
     148        /**
     149         * Whether an import only covers /trunk (no tags added or removed).
     150         *
     151         * @param array $args The args for the import job.
     152         * @return bool
     153         */
     154        protected static function is_trunk_only_update( $args ) {
     155                $tags_touched = (array) ( $args['tags_touched'] ?? array() );
     156                $tags_deleted = (array) ( $args['tags_deleted'] ?? array() );
     157
     158                return $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted;
     159        }
     160
     161        /**
     162         * Whether /trunk/readme.txt's Stable Tag points to a tag that's already in
     163         * /tags/ AND differs from the directory's current stable_tag.
     164         *
     165         * Indicates a release-by-readme-flip — the author isn't going to follow up
     166         * with `svn cp trunk tags/X.Y` because the tag already exists. Used to skip
     167         * the 15-minute trunk grace window in that case.
     168         *
     169         * @param string $plugin_slug The plugin slug.
     170         * @return bool
     171         */
     172        protected static function trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) {
     173                $plugin = Plugin_Directory::get_plugin_post( $plugin_slug );
     174                if ( ! $plugin ) {
     175                        return false;
     176                }
     177
     178                $base_url = "https://plugins.svn.wordpress.org/{$plugin_slug}/trunk";
     179
     180                // Optimistic: try lowercase readme.txt first — covers virtually every plugin without a shell-out.
     181                $readme         = new Readme_Parser( "{$base_url}/readme.txt" );
     182                $new_stable_tag = $readme->stable_tag;
     183
     184                if ( ! $new_stable_tag ) {
     185                        // Empty result could be 404 (readme.md only, or non-lowercase casing) or no Stable Tag header.
     186                        // Fall back to SVN::ls and try whatever else is there.
     187                        $trunk_files  = SVN::ls( $base_url ) ?: array();
     188                        $readme_files = preg_grep( '!^readme\.(txt|md)$!i', $trunk_files );
     189
     190                        foreach ( $readme_files as $f ) {
     191                                if ( 'readme.txt' === $f ) {
     192                                        continue; // Exact match we already tried.
     193                                }
     194                                $readme         = new Readme_Parser( "{$base_url}/{$f}" );
     195                                $new_stable_tag = $readme->stable_tag;
     196                                if ( $new_stable_tag ) {
     197                                        break;
     198                                }
     199                        }
     200                }
     201
     202                if ( ! $new_stable_tag || 'trunk' === $new_stable_tag ) {
     203                        return false;
     204                }
     205
     206                if ( get_post_meta( $plugin->ID, 'stable_tag', true ) === $new_stable_tag ) {
     207                        return false;
     208                }
     209
     210                $tagged_versions = (array) get_post_meta( $plugin->ID, 'tagged_versions', true );
     211
     212                return in_array( $new_stable_tag, $tagged_versions, true );
     213        }
     214
     215        /**
     216         * Merge two plugin_data payloads into a single import-job payload.
     217         *
     218         * Used when folding an already-scheduled future event into a newer request so
     219         * neither set of changes is lost.
     220         *
     221         * @param array $existing The args from the currently-scheduled event.
     222         * @param array $incoming The args from the new request.
     223         * @return array Merged args ready to pass to the import job.
     224         */
     225        protected static function merge_plugin_data( array $existing, array $incoming ) {
     226                $merged = array_merge( $existing, $incoming );
     227
     228                foreach ( array( 'tags_touched', 'tags_deleted', 'revisions' ) as $key ) {
     229                        $existing_values = (array) ( $existing[ $key ] ?? array() );
     230                        $incoming_values = (array) ( $incoming[ $key ] ?? array() );
     231
     232                        $merged[ $key ] = array_values( array_unique( array_merge( $existing_values, $incoming_values ) ) );
     233                }
     234
     235                foreach ( array( 'readme_touched', 'code_touched', 'assets_touched' ) as $key ) {
     236                        $merged[ $key ] = ! empty( $existing[ $key ] ) || ! empty( $incoming[ $key ] );
     237                }
     238
     239                return $merged;
    28240        }
    29241
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php

    r14874 r14879  
    200200         */
    201201        protected function parse_readme( $file_or_url ) {
    202                 $context = stream_context_create( array(
    203                         'http' => array(
    204                                 'user_agent' => 'WordPress.org Plugin Readme Parser',
    205                         )
    206                 ) );
    207 
    208                 $contents = file_get_contents( $file_or_url, false, $context );
     202                $is_http = (bool) preg_match( '!^https?://!i', $file_or_url );
     203
     204                // Prefer wp_safe_remote_get for HTTP fetches — it has a 5s timeout, so a hung readme host can't stall queue() or the SVN watcher. Fall back to a no-timeout file_get_contents when WP isn't loaded (early bootstrap / standalone CLI) or for non-HTTP sources (local files, data URIs).
     205                if ( $is_http && function_exists( 'wp_safe_remote_get' ) ) {
     206                        $response = wp_safe_remote_get(
     207                                $file_or_url,
     208                                array(
     209                                        'user-agent' => 'WordPress.org Plugin Readme Parser',
     210                                )
     211                        );
     212
     213                        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) {
     214                                return false;
     215                        }
     216
     217                        $contents = wp_remote_retrieve_body( $response );
     218                } else {
     219                        $context = stream_context_create( array(
     220                                'http' => array(
     221                                        'user_agent' => 'WordPress.org Plugin Readme Parser',
     222                                ),
     223                        ) );
     224
     225                        // Suppress warnings for the common 404 / unreachable-URL case; downstream callers see an empty parser.
     226                        $contents = @file_get_contents( $file_or_url, false, $context );
     227                }
     228
     229                if ( ! is_string( $contents ) ) {
     230                        return false;
     231                }
    209232
    210233                return $this->parse_readme_contents( $contents );
     
    216239         */
    217240        protected function parse_readme_contents( $contents ) {
     241                // Belt-and-braces: external callers (or future code paths) shouldn't be able to fatal preg_match by passing a non-string.
     242                if ( ! is_string( $contents ) ) {
     243                        return false;
     244                }
     245
    218246                $this->raw_contents = $contents;
    219247
     
    340368                }
    341369                if ( ! empty( $headers['license'] ) ) {
    342                         // Handle "License: GPLv2 - http://..." and wrapped forms like "<http://...>" or "(http://...)".
     370                        // Handle the many cases of "License: GPLv2 - http://..."
    343371                        if ( empty( $headers['license_uri'] ) && preg_match( '!(https?://\S+)!i', $headers['license'], $url ) ) {
    344                                 $headers['license_uri'] = trim( $url[1], " -*\t\n\r\n()<>" );
    345                                 $headers['license']     = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n()<>" );
     372                                $headers['license_uri'] = trim( $url[1], " -*\t\n\r\n(" );
     373                                $headers['license']     = trim( str_replace( $url[1], '', $headers['license'] ), " -*\t\n\r\n(" );
    346374                        }
    347375
     
    619647                list( $key, $value ) = explode( ':', $line, 2 );
    620648                $key                 = strtolower( trim( $key, " \t*-\r\n" ) );
    621                 // Strip `<>` so the markdown autolink form `<https://example.com>` resolves like a bare URL.
    622                 $value               = trim( $value, " \t*-\r\n<>" );
     649                $value               = trim( $value, " \t*-\r\n" );
    623650
    624651                if ( $only_valid && ! isset( $this->valid_headers[ $key ] ) ) {
Note: See TracChangeset for help on using the changeset viewer.