Skip to content

Prevent duplicate scheduling of image processing actions#1094

Open
girishpanchal30 wants to merge 5 commits into
developmentfrom
bugfix/1074
Open

Prevent duplicate scheduling of image processing actions#1094
girishpanchal30 wants to merge 5 commits into
developmentfrom
bugfix/1074

Conversation

@girishpanchal30

@girishpanchal30 girishpanchal30 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

All Submissions:

Changes proposed in this Pull Request:

Added a check to verify whether the optml_start_processing_images action 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:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread inc/media_offload.php Outdated
Comment thread inc/media_offload.php Outdated
@pirate-bot

pirate-bot commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Plugin build for 85a4db1 is ready 🛎️!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 window maybe_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 notoptions entry. maybe_reschedule() calls get_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. Use add_option() for the atomic insert so WordPress maintains both the option and notoptions caches.
			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,

Comment thread inc/media_offload.php Outdated
Comment on lines +2025 to +2079
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a more elegant way than this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have implemented it as you suggested. Please check it and let me know if any changes are needed.

@selul

selul commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

@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.

@girishpanchal30

Copy link
Copy Markdown
Contributor Author

@selul I wasn't able to reproduce the issue in my local environment. However, after reviewing the code, I identified the underlying cause: when number_of_images_and_pages() is called during a media offload, it schedules the optml_start_processing_images action without ensuring that another processing chain hasn't already been scheduled for the same transfer.

As suggested by Copilot, I implemented a locking mechanism that guards scheduling for both the offload_images and rollback_images actions. This ensures that only one processing chain can be scheduled per transfer, eliminating the race condition in which two concurrent requests could otherwise both schedule the same job before either completes.

@selul

selul commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

@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.

@girishpanchal30

Copy link
Copy Markdown
Contributor Author

@selul I have implemented transient as you suggested with the latest commit. Please check it and let me know if any changes are needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants