Prevent duplicate scheduling of image processing actions#1094
Prevent duplicate scheduling of image processing actions#1094girishpanchal30 wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a scheduling guard intended to prevent duplicate image-processing jobs during media offload and rollback.
Changes:
- Checks for an existing processing action before scheduling.
- Leaves concurrent scheduling and running-job scenarios unresolved.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
inc/media_offload.php:68
- The lock can expire while a batch is still processing. A rollback batch defaults to 20 images, and each
download_url()call can block for 60 seconds (inc/media_offload.php:905-906), so one batch can exceed this 600-second TTL. During that windowmaybe_reschedule()can replace the lock and launch another worker against the same images. Renew the lease during long batches (for example, per image) or use a locking design that cannot expire while the owner is actively processing.
const TRANSFER_LOCK_TTL = 600;
inc/media_offload.php:2049
- This direct insert does not clear WordPress's cached
notoptionsentry.maybe_reschedule()callsget_option()earlier on every request, so an absent lock is commonly negative-cached before this insert; with a persistent object cache, the scheduled worker will continue seeing no lock, fail renewal, and stop the transfer. Useadd_option()for the atomic insert so WordPress maintains both the option andnotoptionscaches.
if ( $inserted ) {
wp_cache_delete( self::TRANSFER_LOCK_OPTION, 'options' );
inc/media_offload.php:1919
- The initial scheduling result is ignored after the lock has been acquired. If Action Scheduler or WP-Cron rejects the event, no worker exists but the lock makes repeated starts and
maybe_reschedule()no-op for up to ten minutes. Release the token when enqueueing fails so recovery can happen immediately.
self::schedule_action(
inc/media_offload.php:1915
- This concurrency fix has no tests for duplicate acquisition, ownership-safe renewal/release, expired-lock takeover, or a failed enqueue. These are the behaviors most likely to regress and are not covered by the existing media tests; add focused tests for the lock lifecycle and duplicate start path.
// We acquire a lock to prevent multiple workers from running the same action concurrently.
$lock_token = self::acquire_transfer_lock( $action );
inc/media_offload.php:2270
- A failed continuation enqueue leaves the transfer lock active even though this worker is finished and no next worker exists. Since recovery now checks the lock rather than the scheduler, processing stalls until the lease expires. Check the return value and release the owned lock when scheduling fails.
$lock_token,
inc/media_offload.php:2288
- The retry enqueue can also fail while retaining the lock. In that case there is neither a pending retry nor immediate recovery because
maybe_reschedule()sees an active lock. Release the token when scheduling the retry fails.
$lock_token,
| global $wpdb; | ||
|
|
||
| for ( $attempt = 0; $attempt < 2; $attempt++ ) { | ||
| $token = wp_generate_uuid4(); | ||
| $value = [ | ||
| 'token' => $token, | ||
| 'action' => $action, | ||
| 'expires' => time() + self::TRANSFER_LOCK_TTL, | ||
| ]; | ||
|
|
||
| $suppress = $wpdb->suppress_errors( true ); | ||
|
|
||
| // Attempt to insert a new lock row. If another worker has already inserted one, this will fail. | ||
| $inserted = $wpdb->insert( | ||
| $wpdb->options, | ||
| [ | ||
| 'option_name' => self::TRANSFER_LOCK_OPTION, | ||
| 'option_value' => maybe_serialize( $value ), | ||
| 'autoload' => 'no', | ||
| ] | ||
| ); | ||
| $wpdb->suppress_errors( $suppress ); | ||
|
|
||
| if ( $inserted ) { | ||
| wp_cache_delete( self::TRANSFER_LOCK_OPTION, 'options' ); | ||
| return $token; | ||
| } | ||
|
|
||
| $existing = self::get_transfer_lock(); | ||
|
|
||
| if ( null === $existing ) { | ||
| continue; | ||
| } | ||
|
|
||
| if ( $existing['expires'] >= time() ) { | ||
| return false; | ||
| } | ||
|
|
||
| $updated = $wpdb->update( | ||
| $wpdb->options, | ||
| [ 'option_value' => maybe_serialize( $value ) ], | ||
| [ | ||
| 'option_name' => self::TRANSFER_LOCK_OPTION, | ||
| 'option_value' => maybe_serialize( $existing ), | ||
| ] | ||
| ); | ||
|
|
||
| if ( $updated ) { | ||
| wp_cache_delete( self::TRANSFER_LOCK_OPTION, 'options' ); | ||
| return $token; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
Is there a more elegant way than this?
There was a problem hiding this comment.
Yes, if the existing lock has expired, we delete it as a best-effort operation. It doesn't matter if 0 rows are affected, as another worker may have already cleared or replaced the lock. We then always attempt a plain $wpdb->insert().
Since the outcome of the INSERT is the only thing that determines lock ownership, there is no need for a compare-and-swap (CAS)-conditioned UPDATE or a retry loop.
Please let me know if you have any other thoughts or suggestions.
Thanks!
There was a problem hiding this comment.
I see that WordPress has a similar mechanism when upgrading https://developer.wordpress.org/reference/classes/wp_upgrader/create_lock/
Do you think we can create something similar based on their pattern (you can tell the AI to work based on it)? For the moment, the $wpdb->suppress_errors( $suppress ); does not inspire much safety.
And also, regarding the solution, we should add some tests to simulate this scenario.
There was a problem hiding this comment.
I have implemented it as you suggested. Please check it and let me know if any changes are needed.
|
@girishpanchal30 have you found out the root cause? the scheduling already has enough locking check via wp-cron/action scheduler, i see we reinvent a lot of stuff. |
|
@selul I wasn't able to reproduce the issue in my local environment. However, after reviewing the code, I identified the underlying cause: when As suggested by Copilot, I implemented a locking mechanism that guards scheduling for both the |
|
@girishpanchal30 in this case we can use a simple transient which clears up when the process finish and is setting up when the process start. |
|
@selul I have implemented transient as you suggested with the latest commit. Please check it and let me know if any changes are needed. |
2bed02f to
85a4db1
Compare
All Submissions:
Changes proposed in this Pull Request:
Added a check to verify whether the
optml_start_processing_imagesaction is already scheduled. If it isn't, the action is scheduled. This prevents multiple instances of the same action from being scheduled.Closes #1074
Other information: