Repository navigation
fix: make the transfer lock atomic and clean up files left by interrupted rollbacks - #1191
Conversation
…pted rollbacks Two requests that started a transfer at the same moment could both take the transfer lock, because it was read and then written. The lock is now stored with an insert that fails when the row already exists (wp_cache_add with a persistent object cache). A rollback that died after moving the restored file into uploads left that file behind, so the retry got a suffixed name (.1-1.webp) while references kept the old one. The moved file is now recorded on the attachment until the rollback finishes, the retry removes it first, and the attachment points to the restored file right after the move instead of after the sub-sizes. Refs #1136 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p
There was a problem hiding this comment.
🟡 Changes requested
The interrupted-rollback cleanup misses the main retry path and can delete files wrongly, so please fix the six inline findings.
Questions for the author
- Can release or expiry cleanup delete the timeout row between
inc/media_offload.php:2157and the insert at lines 2160-2167? Then the new lock would have no expiry. - On supported Windows hosts, does
trailingslashit( realpath( $uploads['basedir'] ) )end in/whilerealpath( $pending )uses backslashes?
Validation details
- Files reviewed: 2/2 changed files. The Shared global names rows (Declared names, Guard coverage, Shared copies, Load scope, Isolation) stayed unchecked.
- WP 7.1.3, PHP 8.3, PHPUnit 9.6. The five new tests pass on HEAD [R4].
- On the base tree, the lock test fails on behavior [R7]. The rollback tests fail on behavior with the literal meta key [R6].
- A death during sub-size generation, then a retry, leaves the pending marker set [R16].
Untested areas
- Shared global names: Declared names, Guard coverage, Shared copies, Load scope, Isolation. Open
inc/media_offload.php:974. Confirm thatwp-admin/includes/post.phpnever loads there. It is safe whenupdate_attached_filealways exists. - The
wp_cache_add()branch was not run with a persistent object cache. - No Windows host was available.
- Overlapping REST rollbacks were not run.
🤖 Automated review · run code-review-agent_6ad7c7bb90e7a2.11678870.
🤖 Review agent — review posted ✅ on 9d2d6826 · changes requested · 6 findings · 29 min
Run code-review-agent_6ad7c7bb90e7a2.11678870 · trail
| } | ||
|
|
||
| // A file left behind by an interrupted attempt would make WordPress pick a suffixed name. | ||
| self::delete_pending_rollback_file( $id ); |
There was a problem hiding this comment.
correctness Two single-image rollbacks of the same attachment can overlap. REST move_image calls move_single_image() without the GET_LOCK that the batch chain and WP-CLI hold (inc/rest.php:1083-1093). Both requests pass the metadata gate at lines 878-884. After request A moves its file and starts sub-sizes, request B deletes A's file here. A's editor then writes metadata for a file that is gone, and B gets a suffixed name from A's sub-size files. Take a per-attachment lock around the rollback in move_single_image(), as the batch path does.
There was a problem hiding this comment.
Since the retry no longer deletes any file, this PR doesn't add a way for two overlapping rollbacks to remove each other's file. Two REST rollbacks of the same attachment racing each other was already possible before this change, and a per-attachment lock is outside the scope of #1136, so I'm leaving it.
Generated by Claude Code
There was a problem hiding this comment.
Agreed. The line that deleted the other request's file is gone at this head, so the failure described here no longer happens. Two overlapping REST rollbacks of one attachment were already possible at the base.
…ng it Review follow-up on the rollback cleanup: - the retry writes the downloaded image over the file the interrupted attempt left behind, instead of deleting it before the sideload, so a failed move never leaves the attachment pointing at a missing file; - the pending-file meta is also cleared when the retry ends in the branch for attachments whose local metadata was already saved; - the uploads-dir check normalizes both paths, so it works on Windows; - the lock expiry is written again after the insert; - native parameter and return types on the new helpers. Refs #1136 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p
|
Answers to the review questions:
Generated by Claude Code |
There was a problem hiding this comment.
🟡 Changes requested
The earlier findings are fixed, but a failed copy corrupts the restored file and a lock can lose its expiry. Please fix the three inline findings.
Questions for the author
- What keeps sites that share one object cache from sharing the
wp_cache_add()lock key?
Validation details
- Files reviewed: 2/2 changed files. Three rows stayed unchecked: Stored state lifecycle: Events that make it untrue, Stored state lifecycle: Lifetime, Path comparison: Windows and symlinks.
- The six new and changed tests pass (
OK (6 tests, 18 assertions), run R2). - A failed copy lost the old file (
old_preserved=false, run R1). - A simulated release between the lock writes left no timeout row (run R4).
Untested areas
- The three rows above were not settled.
- No persistent object cache, Windows host or real full disk was available.
🤖 Automated review · run code-review-agent_6ad7d4c06bad09.70184082.
🤖 Review agent — review posted ✅ on b37c91d4 · changes requested · 3 findings · 29 min
Run code-review-agent_6ad7d4c06bad09.70184082 · trail
…piry after a racing release Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p
|
On the shared object cache question: the lock uses the same key and group ( Generated by Claude Code |
There was a problem hiding this comment.
🟡 Changes requested
The three earlier findings are fixed. Please add native types to the new test stream helper.
Questions for the author
- Are the
transientandoptionscache groups isolated per site in every supported object cache, as on the formerset_transient()path?
Validation details
- Files reviewed: 2/2 changed files. Unchecked rows: Stored state lifecycle: Lifetime, Stored state lifecycle: Several records, Path comparison: Windows and symlinks, and the five Shared global names rows.
- The two new tests pass at HEAD
sandbox-db.sh 2>&1 | tail -5; mkdir -p /workspace/checks/C1; cd /repo && set -o pipefail; timeout 300 vendor/bin/phpunit…printedOK (2 tests, 6 assertions)(run R2). - The six earlier tests still pass
cd /repo && set -o pipefail; timeout 300 vendor/bin/phpunit --do-not-cache-result --filter 'Test_Media::test_(rollback_r…printedOK (6 tests, 18 assertions)(run R3). - A failed copy now keeps the old file
python3 /workspace/checks/C2/build.py && timeout 30 php /workspace/checks/C2/check.php prepare && bash -c 'trap "" XFSZ;…printed"old_preserved":true(run R5).
Untested areas
- The unchecked rows above were not settled.
- No persistent object cache, Windows host or real full disk was available.
🤖 Automated review · run code-review-agent_6ad7df564bf714.05237901.
🤖 Review agent — review posted ✅ on 7fc4911e · changes requested · 1 finding · 20 min
Run code-review-agent_6ad7df564bf714.05237901 · trail
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MEXWtYdE6e3ALYtqtP8j2p
|
On the cache group question: yes, as far as this PR can affect it. With an external object cache, Generated by Claude Code |
There was a problem hiding this comment.
🟢 Approved
The PR passes. The last finding, native types on the test stream helper, is fixed.
Validation details
- Files reviewed: 2/2 changed files. Unsettled rows: Stored state lifecycle: Lifetime, Stored state lifecycle: Several records, and the five Shared global names rows.
- The helper and transfer-lock tests pass at HEAD on PHP 8.3:
cd /repo && set -o pipefail; timeout 300 vendor/bin/phpunit --do-not-cache-result --filter 'Test_Media::.*(transfer_lock…printedOK (7 tests, 23 assertions)(run R12) - Windows-style and symlinked upload paths pass the pending-file prefix test with core normalization [R7].
- Earlier fixes were confirmed by run code-review-agent_6ad7df564bf714.05237901 at
7fc4911, not rerun. Production code is unchanged since then.
Untested areas
- The lock lifetime and the two lock rows were probed in the sandbox [R8], but those rows stay unsettled.
- The five Shared global names rows stay unsettled. The guarded core include did not run in a web request [R11].
- No persistent object cache, Windows host or real full disk was available.
🤖 Automated review · run code-review-agent_6ad7e81c63b750.85030625.
🤖 Review agent — review posted ✅ on 0de61782 · approved · 0 findings · 10 min
Run code-review-agent_6ad7e81c63b750.85030625 · trail
Requested by Marius · project thread
All Submissions:
Changes proposed in this Pull Request:
Part of #1136, which reports two failure modes behind the
.1-1.webpfiles and stale references.1. Two transfers could take the transfer lock at once.
acquire_transfer_lock()read the transient and then wrote it, so two requests that started a few seconds apart (as in the customer logs) could both get a token. The lock is now stored with a write that fails when it already exists:wp_cache_add()in thetransientgroup, which is whereset_transient()puts it;INSERT IGNOREon the_transient_optml_transfer_lockrow. The expiry row is written before the insert, so a request that dies right after it can't leave a lock that never expires, and written again after a successful insert (bypassing the cached copy) in case another request's release removed it in between.Both use the same storage as
set_transient(), so renew, release andmaybe_reschedule()keep working unchanged. An expired lock can still be taken again, because theget_transient()read deletes it first.2. A rollback that died after moving the file left it behind.
rollback_and_update_images()moves the downloaded file into uploads before it generates sub-sizes. If the request died in between, the next attempt hit the orphaned file and WordPress picked a suffixed name. Now:_optimole_rollback_pending_fileuntil the rollback of that attachment finishes;wp_normalize_path(), so Windows paths match);update_attached_file()runs right after the move instead of after the sub-sizes, so a request that dies during the slow part still leaves the attachment pointing at the restored file.Not covered here: the shared process meta that concurrent
move_images()starts overwrite (from the customer log analysis in the issue). Concurrent image moves themselves are already serialized by theGET_LOCKadvisory lock from #1173/#1174.How to test the changes in this Pull Request:
intermediate_image_sizes_advanced).-1suffix, and_wp_attached_filematches the file on disk.curlcalls to the start endpoint). Only one chain gets a lock token.Other information:
update_attached_file()/ the skip-branch cleanupinc/media_offload.phpassets/buildincludes in a non-built checkout)Not proven: the
wp_cache_add()path with a real Redis or Memcached object cache, and a live rollback on a real site. Codex review was skipped because the Codex CLI isn't available in this environment.Generated by Claude Code