Repository navigation
fix(sync): Break the sync loop that can overload a server (#1301) - #1328
gabrielcld2 wants to merge 2 commits into
Conversation
Two independent fixes for a scenario where an attachment's sync state stops advancing and the plugin ends up repeatedly reprocessing it, overloading the server with internal /cloudinary/v1/queue requests: 1. Sync::validate_sync_type() recurses into get_sync_type() after saving a signature. If that save doesn't stick, the recursion never terminates, pinning a PHP worker indefinitely. Add a bounded retry counter (filterable via cloudinary_sync_type_retry_limit) that flags a sync error once a type clearly isn't resolving, instead of recursing forever. 2. Push_Sync::process_assets() already had an outer loop guard for when the same sync type repeats in one pass, but it only broke the loop without flagging the attachment. That left it unsynced, unqueued and un-errored, so the next build_queue() run picked it straight back up, repeating the same stuck request. Flag a sync error there too, so build_queue()'s own `_cld_error NOT EXISTS` check excludes it afterwards. Root cause: Media::update_post_meta() / delete_post_meta() do a non-atomic read-modify-write of the whole `_cloudinary` meta blob. Two of these racing for the same attachment -- e.g. a bulk-sync thread and an autosync thread both reaching it, which the existing add_to_thread_queue() "steal" logic allows -- can silently lose one side's write entirely, with nothing logged. Reproduced locally with two concurrent writers and no filter needed. Harden both methods with a compare-and-swap retry (via update_post_meta()'s $prev_value arg), invalidating the primed post-meta cache between attempts so a retry actually sees a concurrent write instead of replaying a stale read. Covered by three new PHPUnit suites: the retry-limit circuit breaker, the outer loop guard flagging + excluding the attachment from the next queue build, and the meta race (on first creation, on an existing row, and for delete_post_meta()).
utkarshcloudinary
left a comment
There was a problem hiding this comment.
Fix is correct left some inline feedback.
| // clearly not resolving, rather than let the recursion run unbounded. | ||
| static $attempts = array(); | ||
| $key = $attachment_id . ':' . $type; | ||
| $attempts[ $key ] = isset( $attempts[ $key ] ) ? $attempts[ $key ] + 1 : 1; |
There was a problem hiding this comment.
blocker: This counter counts calls to validate_sync_type() for the whole request, not the recursion depth of one chain. It never decrements and it never resets.
If an attachment has a pending type that is not realtime (for example options after a settings change), each normal get_sync_type() call gets to this point and returns the type without a write. filter_status(), Delivery (once per URL item), Upload_Sync, and the process_assets() loop all call it many times. In a long request such as WP-CLI sync, a media library render, or a page with many sizes of the same image, the 51st normal call writes _cld_error. After that, the attachment stays out of sync until someone clears the errors manually.
Count only re-entry in the current chain. Decrement the counter when the call returns:
static $depth = array();
$key = $attachment_id . ':' . $type;
$depth[ $key ] = ( $depth[ $key ] ?? 0 ) + 1;
try {
// existing guard + body
} finally {
--$depth[ $key ];
if ( 0 >= $depth[ $key ] ) {
unset( $depth[ $key ] );
}
}This change also stops $attempts from growing without limit during long CLI runs. Also add a test that calls get_sync_type() more than limit times on a stable pending attachment, and asserts that no _cld_error is written. test_does_not_trip_during_normal_resolution makes only one call, so it does not find this. The docblock in test-sync-loop-guard.php says that the counter continues across tests, which shows the same problem.
There was a problem hiding this comment.
Done. implemented the decrement on finally. ff83557
| $attachment_id, | ||
| Sync::META_KEYS['sync_error'], | ||
| __( 'Sync loop detected: sync state is not advancing.', 'cloudinary' ) | ||
| ); |
There was a problem hiding this comment.
warning: This branch now writes a permanent _cld_error when the same type comes back twice in one pass. Before this change, the code only did break, and the next queue build tried the attachment again.
A type can repeat for transient reasons. Examples are a sync callback that returns early without a WP_Error, a third-party sync type, or the concurrent write this PR describes when the CAS fallback at class-media.php:2834 still loses. These attachments now need a manual "clean up errors" step. Before, they recovered by themselves.
Consider a small counter in meta across passes, for example 3 consecutive loop detections before the error is set. Or write a process log entry with log_sync_result() so the reason is visible, and let build_queue() skip the attachment for a cool-down period. As a minimum, include $type in the message, as the validate_sync_type() guard does, so support can tell which state stalled.
There was a problem hiding this comment.
Done. Now it tracks consecutive loop detections in postmeta across passes and only sets _cld_error after 3 strike (by default. number configurable by filter).
| // here would just replay this same process's first (possibly already stale) read on | ||
| // every retry, never seeing what a concurrent process wrote in the meantime. Drop it | ||
| // so each attempt re-queries the DB. | ||
| wp_cache_delete( $post_id, 'post_meta' ); |
There was a problem hiding this comment.
warning: wp_cache_delete( $post_id, 'post_meta' ) runs on every write. It is not only for retries. This deletes the cache for all meta of the attachment. metadata_exists() then runs a full SELECT for the post meta again.
upload_asset() does about 5 update_post_meta() calls and then calls set_signature_item() several times. Thus one upload now does about 10 or more full meta reloads, and with a persistent object cache, the same number of cache deletes and sets. This affects the bulk-sync path that this PR wants to make lighter.
Read from the cache on the first attempt. Call wp_cache_delete() only before a retry, after the CAS failed. The CAS itself (the WHERE meta_value = $prev condition) finds stale reads, so a fresh read is not necessary on the first attempt.
There was a problem hiding this comment.
Done. Moved the cache-bust to only run before a retry.
| $meta[ $key ] = $data; | ||
|
|
||
| if ( $exists ) { | ||
| $result = update_post_meta( $post_id, Sync::META_KEYS['cloudinary'], $meta, $prev ); |
There was a problem hiding this comment.
nit: The CAS is disabled when $prev is empty. update_metadata() does if ( ! empty( $prev_value ) ) before it adds $prev_value to the WHERE. This occurs when the _cloudinary row exists but contains '' or an empty array, for example after meta_cleanup or delete_post_meta() removes the last key. In that case, the write is unconditional again. Also, get_post_meta() returns (array) $meta. If the stored value is not an array, $prev never matches the DB value, so all 3 attempts fail and the code uses the unconditional fallback. These are edge cases, but a short comment would help.
| wp_cache_delete( $post_id, 'post_meta' ); | ||
|
|
||
| $exists = metadata_exists( 'post', $post_id, Sync::META_KEYS['cloudinary'] ); | ||
| $meta = $this->get_post_meta( $post_id ); |
There was a problem hiding this comment.
nit: $exists is read before get_post_meta(). When the row is missing, get_post_meta() applies cloudinary_migrate_legacy_meta. Upgrade::migrate_legacy_meta() can write _cloudinary itself (lines 229 and 259). In that case, add_post_meta( ..., true ) fails and the first attempt is lost. The retry recovers, but if you swap the two lines, this extra attempt does not occur.
Addresses @utkarshcloudinary's review on #1328: - blocker: Sync::validate_sync_type()'s retry counter counted every call for an attachment+type, not recursion depth, so a type that resolves immediately but is legitimately checked many times across a long request (status rendered for several references to the same attachment, media library pages, etc.) could falsely trip the breaker after 50 *total* calls. Switch to a depth counter that increments on entry and decrements on every return via `finally`, so only an actual, still-unresolved recursive chain accumulates. - warning: Push_Sync::process_assets()'s outer loop guard flagged a permanent `_cld_error` the first time a type repeated in one pass, removing the self-healing the plugin had before this PR (the attachment would just get reprocessed on the next queue build). A single repeat can be transient. Track consecutive loop detections across passes in postmeta and only flag the error once that reaches a (filterable) limit, clearing it again on any pass that completes without repeating. Also names the stuck $type in the message, same as the other guard. - warning: Media::update_post_meta()/delete_post_meta() dropped the primed post-meta cache on every attempt, including the first, where it isn't needed -- the compare-and-swap ($prev_value) already catches a stale read by failing against the DB's actual current value. Only invalidate the cache before a retry, once the CAS has actually failed. - nit: reordered the existence check after the read in update_post_meta(), since a cold cache's `cloudinary_migrate_legacy_meta` filter can itself create the row as a side effect; checking existence first could miss that and cost an avoidable extra retry. - nit: documented that update_metadata()'s CAS is skipped when $prev is empty (e.g. right after meta_cleanup) or not an array, so that specific transition falls through to an unconditional write. Test changes: - test-sync-loop-guard.php: added a regression test confirming 20 independent, non-recursing calls for a stable pending type never trip the breaker (4x the test's own retry limit), matching the blocker above. Added matching sync_base_struct teardown so its custom type doesn't leak into other tests. - test-push-sync-outer-loop-guard.php: updated for the 3-strike behaviour -- a single repeat no longer errors, the error only lands (naming the type) once the limit is reached, and a successful pass resets the strike count. Re-verified manually against the live, connected wp-env instance: the original issue's exact repro still resolves via the inner guard in one pass; a genuine two-process race on a fresh attachment still merges both writes; and a normal upload still fully syncs.
utkarshcloudinary
left a comment
There was a problem hiding this comment.
Looks good; Left minor feedback.
| $loop_limit = apply_filters( 'cloudinary_sync_loop_strikes', 3, $type, $attachment_id ); | ||
| if ( $loop_count >= $loop_limit ) { | ||
| update_post_meta( | ||
| $attachment_id, | ||
| Sync::META_KEYS['sync_error'], |
There was a problem hiding this comment.
warning: With the "Cloudinary only" storage mode, the strike counter can set a permanent error on a healthy attachment.
Storage::validate()saves thedelaymeta and returns true while the local file exists (class-storage.php:310-319).Storage::sync()callscan_sync( $attachment_id, 'storage' ). Thedelay_cld_only()filter returns false forDELETE_DELAY(60 seconds), sosync()returns at line 156 and does not update the signature. This delay is intentional.- Thus
storagecomes back a second time inprocess_assets(), and the attachment gets one strike. Before this PR, the loop only stopped and the next run tried again. Now, 3 runs during the 60-second delay set_cld_error. Thenbuild_queue()skips the attachment, and it stays errored after the delay ends until someone manually cleans up the errors.
Auto sync handles new uploads immediately, so 3 runs in 60 seconds is possible.
Suggested fix: do not count a strike when can_sync() returns false for the repeated type. A more general option is to let a sync type tell the loop that it is waiting, because third-party sync types can also wait on purpose.
Suggested test: set the cld offload mode with a recent delay value, call process_assets() 3 times, and assert that no _cld_error is set.
What this fixes
Fixes #1301. When an attachment's sync state stops advancing, the plugin could get stuck repeatedly reprocessing it, sending internal requests to
/wp-json/cloudinary/v1/queueuntil the server runs out of PHP workers.Root cause
The plugin keeps all of an attachment's sync progress in one saved record. Updating it means: read the current record, change one part, save it all back. If two sync passes do this for the same attachment close together in time — e.g. a bulk sync and the automatic background sync both reaching it — one can silently overwrite the other's change, with nothing logged. Whichever change is lost looks to the plugin like that step never happened, so it retries, loses the update again, and never progresses.
On top of that, the retry logic for a stuck step had no limit and never flagged the attachment as failed, so it looped indefinitely instead of giving up and reporting an error.
This PR makes the save safe to run from two processes at once, and makes the retry logic give up and flag a sync error after a reasonable number of attempts instead of looping forever.
How to reproduce
No special filter or conflicting plugin needed — just two sync passes landing on the same, freshly uploaded attachment close together (routine with auto sync + a bulk sync running at the same time). In PHP, against a never-synced attachment ID:
Before this fix, once A finishes it silently overwrites B's change. After this fix, both survive.
Testing
Three new PHPUnit suites (retry limit, queue-level guard, concurrent writes). Also verified manually on a real connected site: the original report's exact repro now resolves in seconds with a clear error instead of hanging, the race above no longer loses a write, and normal syncing is unaffected.