Skip to:
Content

bbPress.org


Ignore:
Timestamp:
09/10/2026 12:35:43 AM (2 weeks ago)
Author:
johnjamesjacoby
Message:

Counts: synchronize metadata across concurrent writes.

Introduce bbp_bump_count_meta() to update existing numeric metadata with bounded compare-and-swap retries while preserving WordPress metadata filters, actions, sanitization, and cache invalidation. Apply atomic differences to forum, topic, reply, ancestor, and user contribution counts.

Reconcile counts, engagements, and voices across status transitions, permanent deletion, author changes and reassignment, moderator move/merge/split operations, and repair recounts. Correct recursive forum totals and reply visibility, and preserve term-backed favorites and subscriptions during engagement rebuilds.

Document the count-hook compatibility changes and new helper arguments, and add regression coverage for single-site and multisite workflows.

In trunk, for 2.7.

Props alex-ye.
Fixes #2233.
Fixes #3678.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/includes/core/abstraction.php

    r7447 r7467  
    147147function bbp_db() {
    148148        return bbp_get_global_object( 'wpdb', 'WPDB' );
     149}
     150
     151/**
     152 * Atomically bump a numeric metadata value using compare-and-swap retries.
     153 *
     154 * Count updates normally require reading a value, changing it in PHP, and
     155 * writing it back. Two requests can read the same value and overwrite one
     156 * another's changes. This function avoids that lost update by making the write
     157 * conditional on the value that was read. If another request changes the value
     158 * first, the condition matches no rows, so the current value is read directly
     159 * from the database and the calculation is retried.
     160 *
     161 * The first read uses the WordPress metadata API and its cache. Missing
     162 * metadata is added through add_metadata() so its standard lifecycle continues
     163 * to run. Existing metadata uses a conditional database update while preserving
     164 * the standard update metadata short-circuit filter and before/after actions.
     165 * Before actions run for every conditional attempt, while after actions run
     166 * only after a successful write. Metadata caches are cleared between attempts
     167 * and after successful writes. Values are sanitized through sanitize_meta(),
     168 * cast to integers, and prevented from falling below zero.
     169 *
     170 * Retries are bounded and filterable. This function does not lock rows or hold
     171 * a database transaction open, and returns false when there is no change, a
     172 * database operation fails, or all attempts lose to concurrent writes. It is
     173 * intended for uniquely keyed numeric count metadata; WordPress metadata tables
     174 * do not enforce uniqueness during simultaneous first-time inserts.
     175 *
     176 * @since 2.6.16
     177 *
     178 * @see https://bbpress.trac.wordpress.org/ticket/3678
     179 *
     180 * @param string $meta_type  Type of object metadata is for.
     181 * @param int    $object_id  ID of the object metadata is for.
     182 * @param string $meta_key   Metadata key.
     183 * @param int    $difference Amount to add to the stored value.
     184 * @param int    $default    Existing value to use when metadata is missing.
     185 * @return bool True on success, false on failure or no change.
     186 */
     187function bbp_bump_count_meta( $meta_type = '', $object_id = 0, $meta_key = '', $difference = 1, $default = 0 ) {
     188
     189        $object_id  = (int) $object_id;
     190        $difference = (int) $difference;
     191        $default    = (int) $default;
     192
     193        // Bail if required values are missing
     194        if ( empty( $object_id ) || empty( $meta_key ) || empty( $difference ) ) {
     195                return false;
     196        }
     197
     198        /**
     199         * Short-circuits bumping numeric metadata.
     200         *
     201         * Returning a non-null value prevents the normal metadata update.
     202         *
     203         * @since 2.6.16
     204         *
     205         * @param null|bool $check      Whether to short-circuit the metadata update.
     206         * @param string    $meta_type  Type of object metadata is for.
     207         * @param int       $object_id  ID of the object metadata is for.
     208         * @param string    $meta_key   Metadata key.
     209         * @param int       $difference Amount to add to the stored value.
     210         * @param int       $default    Existing value to use when metadata is missing.
     211         */
     212        $check = apply_filters( 'bbp_pre_bump_count_meta', null, $meta_type, $object_id, $meta_key, $difference, $default );
     213        if ( null !== $check ) {
     214                return (bool) $check;
     215        }
     216
     217        /**
     218         * Filters the metadata types that support atomic count updates.
     219         *
     220         * @since 2.6.16
     221         *
     222         * @param array  $meta_types Supported metadata types.
     223         * @param string $meta_type  Requested metadata type.
     224         * @param int    $object_id  ID of the object metadata is for.
     225         * @param string $meta_key   Metadata key.
     226         */
     227        $meta_types = (array) apply_filters( 'bbp_bump_count_meta_types', array( 'post', 'user', 'term', 'comment' ), $meta_type, $object_id, $meta_key );
     228
     229        // Bail if the metadata type is unsupported
     230        if ( ! in_array( $meta_type, $meta_types, true ) ) {
     231                return false;
     232        }
     233
     234        $bbp_db     = bbp_db();
     235        $table_name = sanitize_key( $meta_type . 'meta' );
     236        $table      = isset( $bbp_db->{$table_name} ) ? $bbp_db->{$table_name} : '';
     237        $column     = sanitize_key( $meta_type . '_id' );
     238        $id_column  = ( 'user' === $meta_type ) ? 'umeta_id' : 'meta_id';
     239
     240        // Bail if the metadata table does not exist
     241        if ( empty( $table ) ) {
     242                return false;
     243        }
     244
     245        /**
     246         * Filters the maximum number of conditional metadata write attempts.
     247         *
     248         * @since 2.6.16
     249         *
     250         * @param int    $max_attempts Maximum number of attempts.
     251         * @param string $meta_type    Type of object metadata is for.
     252         * @param int    $object_id    ID of the object metadata is for.
     253         * @param string $meta_key     Metadata key.
     254         * @param int    $difference   Amount to add to the stored value.
     255         * @param int    $default      Existing value to use when metadata is missing.
     256         */
     257        $max_attempts = (int) apply_filters( 'bbp_bump_count_meta_max_attempts', 5, $meta_type, $object_id, $meta_key, $difference, $default );
     258        $max_attempts = max( 1, $max_attempts );
     259        $checked      = false;
     260        $subtype      = get_object_subtype( $meta_type, $object_id );
     261        $count_query  = $bbp_db->prepare( "SELECT meta_value FROM {$table} WHERE meta_key = %s AND {$column} = %d LIMIT 1", $meta_key, $object_id );
     262
     263        // Retry when another request updates the same value first
     264        for ( $attempt = 0; $attempt < $max_attempts; $attempt++ ) {
     265                if ( empty( $attempt ) ) {
     266                        $exists = metadata_exists( $meta_type, $object_id, $meta_key );
     267                        $count  = $exists
     268                                ? (int) get_metadata( $meta_type, $object_id, $meta_key, true )
     269                                : $default;
     270                } else {
     271                        $stored = $bbp_db->get_var( $count_query );
     272
     273                        // Bail on a database error
     274                        if ( ! empty( $bbp_db->last_error ) ) {
     275                                return false;
     276                        }
     277
     278                        $exists = null !== $stored;
     279                        $count  = $exists ? (int) $stored : $default;
     280                }
     281
     282                $new_count = sanitize_meta( $meta_key, bbp_number_not_negative( $count + $difference ), $meta_type, $subtype );
     283                $new_count = (int) $new_count;
     284
     285                // Allow metadata updates to be short-circuited as usual
     286                if ( ! $checked ) {
     287                        $checked = true;
     288                        $check   = apply_filters( "update_{$meta_type}_metadata", null, $object_id, $meta_key, $new_count, '' );
     289
     290                        if ( null !== $check ) {
     291                                return (bool) $check;
     292                        }
     293                }
     294
     295                // Bail if the count is already at its lower bound
     296                if ( $new_count === $count ) {
     297                        if ( ! empty( $attempt ) ) {
     298                                return false;
     299                        }
     300
     301                        $stored = $bbp_db->get_var( $count_query );
     302
     303                        // Bail on a database error
     304                        if ( ! empty( $bbp_db->last_error ) ) {
     305                                return false;
     306                        }
     307
     308                        $current_exists = null !== $stored;
     309                        $current_count  = $current_exists ? (int) $stored : $default;
     310
     311                        if ( ( $current_exists === $exists ) && ( $current_count === $count ) ) {
     312                                return false;
     313                        }
     314
     315                        wp_cache_delete( $object_id, $meta_type . '_meta' );
     316                        continue;
     317                }
     318
     319                // Add missing metadata using the standard WordPress lifecycle
     320                if ( ! $exists ) {
     321                        if ( ! empty( add_metadata( $meta_type, $object_id, $meta_key, $new_count, true ) ) ) {
     322                                return true;
     323                        }
     324
     325                        $stored = $bbp_db->get_var( $count_query );
     326
     327                        // Bail if the add failed without a concurrent insert or on a database error
     328                        if ( ! empty( $bbp_db->last_error ) || ( null === $stored ) ) {
     329                                return false;
     330                        }
     331
     332                        wp_cache_delete( $object_id, $meta_type . '_meta' );
     333                        continue;
     334                }
     335
     336                $meta_ids = $bbp_db->get_col( $bbp_db->prepare( "SELECT {$id_column} FROM {$table} WHERE meta_key = %s AND {$column} = %d", $meta_key, $object_id ) );
     337
     338                // Bail on a database error
     339                if ( ! empty( $bbp_db->last_error ) ) {
     340                        return false;
     341                }
     342
     343                // Retry if metadata was removed after the cached existence check
     344                if ( empty( $meta_ids ) ) {
     345                        wp_cache_delete( $object_id, $meta_type . '_meta' );
     346                        continue;
     347                }
     348
     349                // Run the standard actions immediately before the conditional update
     350                foreach ( $meta_ids as $meta_id ) {
     351                        do_action( "update_{$meta_type}_meta", $meta_id, $object_id, $meta_key, $new_count );
     352
     353                        if ( 'post' === $meta_type ) {
     354                                do_action( 'update_postmeta', $meta_id, $object_id, $meta_key, $new_count );
     355                        }
     356                }
     357
     358                // Compare count metadata numerically to normalize stored numeric strings
     359                $updated = $bbp_db->update(
     360                        $table,
     361                        array( 'meta_value' => $new_count ),
     362                        array(
     363                                $column      => $object_id,
     364                                'meta_key'   => $meta_key,
     365                                'meta_value' => $count
     366                        ),
     367                        array( '%d' ),
     368                        array( '%d', '%s', '%d' )
     369                );
     370
     371                // Bail on a database error
     372                if ( false === $updated ) {
     373                        return false;
     374                }
     375
     376                wp_cache_delete( $object_id, $meta_type . '_meta' );
     377
     378                // Retry when another request updated the count first
     379                if ( empty( $updated ) ) {
     380                        continue;
     381                }
     382
     383                // Run the standard actions immediately after the conditional update
     384                foreach ( $meta_ids as $meta_id ) {
     385                        do_action( "updated_{$meta_type}_meta", $meta_id, $object_id, $meta_key, $new_count );
     386
     387                        if ( 'post' === $meta_type ) {
     388                                do_action( 'updated_postmeta', $meta_id, $object_id, $meta_key, $new_count );
     389                        }
     390                }
     391
     392                return true;
     393        }
     394
     395        return false;
    149396}
    150397
Note: See TracChangeset for help on using the changeset viewer.