Repository navigation
Sponsors: store agreement attachments with the private status - #1992
Conversation
- Mark the attachment a sponsor names as its agreement, and give it the `private` status rather than the `inherit` it gets from the Media modal - Leave marked attachments out of the XML-RPC and admin-ajax media details for users who can't read them - Add a random suffix to the names of files uploaded to a sponsor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJTGFnceNhPYJGb2F32T5r
- Add `wp wc-sponsor-agreements scan` and `backfill`, to give agreements attached before the previous commit the same status and file name - Load the command from the WP-CLI bootstrap Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJTGFnceNhPYJGb2F32T5r
|
I tried the backfill against a 3000x3000 JPEG agreement (a phone photo of a signed page is probably the most common case here). WP stores that as PDFs and images under 2560px are fine, their generated files start with the same base name. I suggest using |
- Take the new base name from `original_image`, so that a scaled image's full-size original and its sizes move along with the attached file - Require the name separator before treating a file as a derivative - Count each file, and report an agreement as unfinished when any of them is left behind or its file is already gone - Render the report with `format_items()`, keeping the non-table formats parsable on STDOUT and the summary on STDERR Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJTGFnceNhPYJGb2F32T5r
|
@mcliwanow Image-size files should now be handled correctly by the backfill. That's a good catch! |
bor0
left a comment
There was a problem hiding this comment.
Focused pass over the CLI, as asked.
The shape is right. The direct-table scan that avoids switch_to_blog() is a good call, the reasoning about ms_upload_constants() matches what the code actually does, and rename_agreement_file() handles the genuinely awkward parts carefully -- scaled originals, two sizes naming one file, the - separator that tells photo-150x150.jpg from photobooth-150x150.jpg -- each with a test pinning it.
Everything below is in the command layer rather than in the helpers, and I don't think that's a coincidence: Command::scan() and Command::backfill() are the only things in this file with no test coverage, while every function they call has some. The ordering issue at L398-399 would have surfaced the moment anyone wrote a test that runs backfill twice.
Ranked:
- L398-399 -- the rename half isn't resumable. Setting the status first removes the attachment from the only query that can ever find it again. This contradicts "Safe to re-run" in the docblock and in the PR description. The one I'd call blocking.
- L359 --
--skip-renamemakes that permanent for every agreement on a site, and reports success while doing it. - L310 -- a failed query reads as a clean site, so
scancan under-report and nothing in the output says so. - L206 -- a crash mid-loop leaves disk and DB out of step, on a command whose own docblock cites memory pressure on the sandbox as a known problem.
- L108 --
file missingconflates an absent file with a misconfigured run, which is the exact mistake the one-site-per-process rule exists to prevent. - L393 --
--dry-rundoesn't enumerate anything, and no run names the site it acted on.
None of this is wrong on the happy path. It's the recovery and reporting paths, which for a one-shot migration across the network are the ones that decide whether a bad run is noticed and fixable. I'd want a couple of tests over backfill() itself -- run twice, and run with a rename that fails -- before this goes near the sandbox.
| $migrated = make_agreement_private( $agreement_id ); | ||
| $outcome = $skip_rename ? $untouched : rename_agreement_file( $agreement_id ); |
There was a problem hiding this comment.
The status change lands before the rename, and get_public_agreement_ids() only returns post_status = 'inherit' rows -- so once make_agreement_private() has succeeded, no later backfill can see this attachment again.
That makes the rename half a single-attempt operation, which the docblock above ("Safe to re-run: a site with nothing outstanding does one query and stops") and the PR description ("Idempotent and resumable") both promise it isn't. Concretely: a run reports 1 moved, 3 left, the operator fixes whatever caused it, re-runs, and gets No sponsor agreements left to migrate. The three files keep the names they had, and nothing is left that can pick them up.
Swapping the two lines fixes it without any extra state -- rename first, set the status only once the rename came back clean. A crash between the two then leaves the row inherit, so the next run redoes both; worst case is a second suffix on an already-renamed file, which is harmless and visible.
| WHERE sponsor_meta.meta_key IN ( $key_placeholders ) | ||
| AND sponsor.post_type IN ( $type_placeholders ) | ||
| AND agreement.post_type = 'attachment' | ||
| AND agreement.post_status = 'inherit'", |
There was a problem hiding this comment.
The other half of the resumability point at L398. inherit is the only thing that makes an attachment visible to this migration, and backfill() clears it on the first pass regardless of what happened on disk afterwards.
If the ordering in backfill() stays as it is, this query needs a second arm -- attachments already carrying _wcorg_sponsor_agreement whose file still has no suffix -- otherwise the command gets exactly one attempt per attachment and no way to tell, later, which attempts fell short.
| * [--dry-run] | ||
| * : Report what would change, without changing it. | ||
| * | ||
| * [--skip-rename] |
There was a problem hiding this comment.
Worth reconsidering while the ordering at L398 stands: --skip-rename sets the status on every agreement on the site and touches no file, which by the query at L64 permanently forecloses the rename for all of them -- and the run ends on %d agreements processed., i.e. reports success.
If it's here for a staged rollout, inverting it (--status-only being the arm that can't run first) would keep the option safe. Otherwise --dry-run already covers the look-before-you-leap case and this could just go.
| ); | ||
| $attached_path = get_attached_file( $attachment_id ); | ||
|
|
||
| if ( ! $attached_path || ! is_file( $attached_path ) ) { |
There was a problem hiding this comment.
This early return yields renamed: 0, left_behind: 0, which describe_rename() renders as file missing. Accurate for a genuinely absent file -- but it is also exactly what the operator sees when the process is pointed at the wrong uploads directory, i.e. the ms_upload_constants() / UPLOADS trap the docblock warns about. In that case every row reads Private: yes / file missing, the summary is a plain success, and nothing distinguishes it from a site whose files were already gone.
Two cheap separations: carry $attached_path out in the outcome so the report can print what it looked for, and have backfill() assert up front that wp_upload_dir()['basedir'] belongs to the site it is about to migrate. The whole one-site-per-process rule currently rests on a comment; four lines would make it rest on the code.
| $found = 0; | ||
|
|
||
| // A site whose tables have gone is a broken row, not a reason to stop part-way through a network. | ||
| $wpdb->suppress_errors( true ); |
There was a problem hiding this comment.
get_col() returns array() when the query fails, so with errors suppressed a broken site is indistinguishable from a site with nothing to migrate. It isn't just skipped -- it is counted as clean in the %d of %d sites have agreements to migrate. summary, and its URL never reaches the pipe that drives backfill.
On a network this size that's the failure mode least likely to be noticed. Reading $wpdb->last_error after each call and reporting the sites that errored on STDERR would keep the "a broken row isn't a reason to stop" behaviour while making the gap visible.
Minor, same call: suppress_errors() returns the previous value. Capturing it and restoring that at L334, rather than forcing false, keeps this from changing state it didn't own.
| $metadata['file'] = $path_prefix . $new_attached; | ||
| } | ||
|
|
||
| if ( $metadata ) { |
There was a problem hiding this comment.
The metadata and _wp_attached_file are written once, after every @rename() has already happened. If the process dies inside the loop -- and the scan docblock cites real memory pressure on the production sandbox -- the files sit on disk under their new names while the DB still points at the old ones. The attachment stops resolving, the files are orphaned, and the mapping between them exists nowhere.
Moving these two writes inside $move(), or flushing after each successful rename, narrows the window to a single file. If that's more churn than you want in a file that's being deleted anyway, the deployment notes should at least carry a recovery procedure, because reconstructing the pairing afterwards means matching files to attachments by hand.
| 'left_behind' => 0, | ||
| ); | ||
|
|
||
| foreach ( $agreement_ids as $agreement_id ) { |
There was a problem hiding this comment.
Two things that would help on a run this wide:
--dry-runreportspendingand nothing else. For a command that renames files across the network, a dry run that doesn't name the files it intends to move (attached file,original_image, each size) or the base it would give them is hard to sign off on -- andrename_agreement_file()already computes all of that before it touches anything.- Nothing in the output names the site being migrated.
--urlis easy to forget, and forgetting it quietly targets the main site instead. Printinghome_url()above the table would make each run self-documenting, which matters when that output is the only record the operator keeps.
Also worth a WP_CLI::confirm() on the non-dry-run path with the usual --yes escape, given what the command does.
- Drop the file renaming, `--skip-rename`, and the `Renamed` column, so that the migration reads and writes the database only - Report the sites `scan` could not read, rather than counting them clean - Name the site each `backfill` run acted on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJTGFnceNhPYJGb2F32T5r
|
Latest commit narrows the migration to setting the attachment status. The file renaming has been split out and will be handled separately, so this can be deployed and the status backfill run while that's still being worked out. @bor0 that removes most of what your review covered — the ordering, |
…uploaded - Move the CSPRN from `wp_unique_filename` to the meta hook, so that a file picked from the Media Library is covered and a sponsor's other uploads are not renamed - Restore `rename_agreement_file()`, called after the status is set and never fatal - Record the attachments the migration acts on, as the work list for renaming the files attached before this Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJTGFnceNhPYJGb2F32T5r
- Name the file at `wp_unique_filename` and set the status at `wp_insert_attachment_data`, so an agreement is never written under the name it arrived with or with the status of its sponsor - Treat any PDF uploaded against a sponsor as an agreement, whether or not the modal marked it - Drop the `wp_prepare_attachment_for_js` filter that did this afterwards Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Log `sponsor_agreement_files_not_renamed` when `rename_agreement_file()` couldn't move everything - Correct the docblock claim that nothing links to an agreement by URL Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Move the meta key to `sponsor-agreements.php` and name it for what it says about the file rather than for what wrote it - Set it from `secure_agreement()` too, so a rename that left files behind is findable and not only logged Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Took another pass over the branch, now at Ran the suites locally:
Also spot-checked that each fix in the last commit has a test behind it: reverting any one of the three on its own fails exactly one test. It does. My notes on Nothing blocking from me. |
- Count an edited image's pre-edit files, recorded in `_wp_attachment_backup_sizes`, as left behind, without moving them - Record an attachment whose file is not on disk rather than treating it as finished - Count files the metadata names that aren't the attachment's own apart from `left_behind`, so a row can come off the list Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
bor0
left a comment
There was a problem hiding this comment.
Approving at 331b51e9.
My notes on rename_agreement_file() are all addressed, and each fix has a test that fails when that fix alone is reverted. I checked that rather than taking it: reverting any one of the three fails exactly one test.
Suites on the branch: 45/45 in --group sponsor-agreements, and 417 in the full WordCamp MU Plugins suite with only the QuickBooks failure that fails the same way on production. CI is green on 8.3, 8.4 and 8.5.
I have two small follow-on points about the deferred file work, left with the team in the usual place. Both are notes for whoever picks that up rather than changes to this branch, so they are not holding anything here.
Worth waiting on @mcliwanow before merging, since they raised the scaled-image case that led to the rewrite and had the last word on the approach.
- Skip backup-size entries that aren't arrays, rather than relying on `??` - Say that `rename_agreement_file()` runs once per attachment, and that a work-list row can be stale by the time it's read Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mcliwanow
left a comment
There was a problem hiding this comment.
The scaled-image case is handled and tested, and the upload path on wp_unique_filename / wp_insert_attachment_data / add_attachment is what I had in mind. Checked against core trunk that wp_ajax_upload_attachment() runs the nonce, upload_files and edit_post checks before any of those fire. Approving.
Makes sponsor agreement attachments private, rather than letting them inherit their sponsor's status, and gives them a random suffix in the name as
wordcamp-paymentsdoes for its own files. A sponsor's other uploads — its logo, images in its content — are left alone.An agreement uploaded from the Sponsor screen is named and made private in the request that creates it, so nothing has to be moved afterwards. One chosen from the existing Media Library is handled when the sponsor is saved, which does rename the file it already had.
Adds a one-time
wp wc-sponsor-agreementsmigration for agreements attached before this, to be removed once it has been run. It sets the status and records that the file still needs renaming; renaming is being handled separately.Testing
On an event site, e.g.
https://seattle.wordcamp.test/2023/, as an admin.wp --url=<site> eval 'echo get_post( <ATTACHMENT_ID> )->post_status;'→private.wcb-spon.js.inheritunder the names they were uploaded with.privateand is renamed on save./wp-json/wp/v2/sponsors/<SPONSOR_ID>still reports"status":"publish".The migration, for agreements attached before this:
wp wc-sponsor-agreements scan --verbosereports which sites have any.wp --url=<site> wc-sponsor-agreements backfill --dry-run, then without--dry-run. Each is reportedPrivate: yes, a secondscanreports none left, and View Agreement still opens the file. The files keep their names — they are recorded for the separate rename pass with_wcorg_sponsor_agreement_needs_rename.One site per process, so across the network:
Automated:
phpunit --testsuite "WordCamp MU Plugins" --group sponsor-agreements(45 tests). The fullWordCamp MU Plugins,WordCamp Post TypesandWordCamp Paymentssuites pass, apart from failures that also occur onproduction— which ones varies by environment.