fix: expire stale Instant Sharing queue entries instead of flooding accounts - #1104
fix: expire stale Instant Sharing queue entries instead of flooding accounts#1104lucadobrescu wants to merge 3 commits into
Conversation
Fixes four defects in the publish-now queue reported in #1102: - WP_Query ignores numberposts, so the drain batch silently fell back to the posts_per_page option; use posts_per_page => 300 - queue entries had no staleness cutoff, so a stalled cron blasted months-old posts on recovery; entries older than a filterable rop_publish_now_expiration (default 1 day) are now marked expired instead of shared - routine edits of already-published posts re-queued shares from leftover meta; wp_after_insert_post now only queues on new publishes unless the classic metabox explicitly submits publish_now - entries with no accounts left rop_publish_now_status stuck at queued forever; the status is now cleared when they are skipped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PHPUnit (tests/test-publish-now.php, new `publish-now` suite): 8 of the 15 tests fail on unfixed development, one per defect — stale entries sharing, a backlog delaying the fresh post, the batch capped at posts_per_page, orphan entries stuck at queued, an edit of a published post re-queueing, and the metabox pre-checking itself on archive content. The other 7 are guards for the paths that must keep sharing: publishing a draft, a scheduled post going live, an explicit Classic Editor submit, and the Block Editor re-share action. The edit test clears `rop_maybe_publish_now_<id>` first. Publishing sets that transient for a minute, so without clearing it the test passes on unfixed code for the wrong reason instead of exercising the defect. E2E (publish-now-backlog.spec.js): seeds three months-old queue entries via a new `/queued-post` endpoint, publishes a post through the editor and asserts the mocked X API only ever receives the fresh one. On unfixed code all four posts are shared — the customer's flood. A second test shares an entry queued seconds ago so the expiry cannot regress into dropping legitimate shares. Also clears leftover `rop_publish_now_history` in `/reset`, which otherwise leaks between runs, and adds `/publish-now-state` so tests can assert on queue meta rather than on editor UI a cron run can erase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes stale Instant Sharing queues so expired posts are skipped instead of shared after cron recovery.
Changes:
- Adds expiration and orphan handling for queued shares.
- Prevents routine edits from re-queuing published posts.
- Adds PHPUnit and E2E regression coverage.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
includes/class-rop.php |
Registers the new post-save wrapper. |
includes/admin/class-rop-admin.php |
Adjusts post-save and metabox behavior. |
includes/admin/models/class-rop-queue-model.php |
Expires stale queue entries. |
includes/admin/models/class-rop-posts-selector-model.php |
Corrects the query batch parameter. |
phpunit.xml |
Registers the publish-now test suite. |
tests/test-publish-now.php |
Adds queue regression tests. |
tests/e2e/fixtures/index.js |
Exposes new E2E helpers. |
tests/e2e/mu-plugins/rop-e2e-bootstrap.php |
Adds backlog fixtures and state endpoints. |
tests/e2e/specs/dashboard/publish-now-backlog.spec.js |
Tests stale-backlog behavior end to end. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ), | ||
| ), | ||
| 'numberposts' => 300, | ||
| 'posts_per_page' => 300, // NOTE: WP_Query ignores `numberposts`; without this the batch silently fell back to the site's `posts_per_page` option. |
There was a problem hiding this comment.
Fixed in 79677b0. Confirmed the gap: the drain runs on a single event and rop_cron_job_publish_now never scheduled a follow-up, so with more than one batch of queued rows the oldest 300 were retired and everything after them — possibly including the just-published post — waited for an unrelated share event.
build_queue_publish_now() now calls manage_cron( array( 'action' => 'publish-now' ) ) when the selector returned a full batch. This terminates: get_publish_now_posts() sets rop_publish_now = 'no' on every row it returns, so each pass removes its own batch from the query window and the window strictly shrinks.
The batch size moved to Rop_Posts_Selector_Model::PUBLISH_NOW_BATCH_SIZE behind a new rop_publish_now_batch_size filter, which also makes it testable — test_full_batch_schedules_another_pass drops it to 2, queues 3 entries and asserts a follow-up event exists; test_partial_batch_does_not_reschedule guards the other direction so a drained queue does not keep rescheduling itself.
| if ( ! $accounts || ! is_array( $accounts ) ) { | ||
| // NOTE: clear the status, otherwise the entry lingers as "queued" forever. | ||
| delete_post_meta( $post_id, 'rop_publish_now_status' ); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Good catch, fixed in 79677b0. Verified against src/instant/PostUpdate.js — isQueued = history.some( item => 'queued' === item.status ) at :134, and :155 renders the spinner on 'queued' === status || isQueued, so a leftover queued history row does keep the sidebar polling every 5s indefinitely even with the top-level status gone.
The orphan branch now retires the entry through expire_publish_now() instead of just deleting the status, so the accounts meta, the status and the history rows are all cleaned up together. expire_publish_now() took an optional $reason so the log line distinguishes the two cases — "has no accounts left to share to" vs "expired before it could be shared".
Covered by test_orphan_entry_retires_history, which asserts no queued row survives; it fails on the previous commit.
| if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status && empty( $_POST['publish_now'] ) ) { | ||
| return; |
There was a problem hiding this comment.
You're right, and this was the sharpest of the three — fixed in 79677b0.
The interaction is exactly as described: publish_now_attributes() pre-checks the box precisely when rop_publish_now is yes, i.e. when a share is still pending, so every ordinary Classic Editor save of such a post submits publish_now, passes the guard, and update_publish_now_history() merges into the existing queued row and overwrites timestamp with time(). A stalled entry's expiry clock reset on every edit, which defeats the cutoff this PR adds.
I went with "leave an already-queued request alone" rather than preserving the timestamp inside update_publish_now_history(), because the deliberate re-share path (REST share/{id} → rop_publish_now_instant_share → maybe_publish_now( $id, true )) merges into that same row and does want a fresh timestamp — preserving it unconditionally would make an explicit re-share expire instantly. Since a post that is already queued has nothing to add, maybe_publish_now_after_insert() now returns early on published posts when rop_publish_now_status is queued, which keeps the original timestamp intact as a side effect. Classic re-shares of a published post that is not already queued still work.
test_saving_a_pending_share_does_not_refresh_its_timestamp covers it: a post queued 5 days ago, edited with publish_now submitted, must keep its original timestamp and must still expire instead of sharing. On the previous commit it fails with the timestamp exactly 432000s newer.
- reschedule another drain pass when a full batch is consumed, so a fresh post behind a large stale backlog is not stranded until an unrelated share event (batch size now filterable via rop_publish_now_batch_size) - retire orphaned entries (no accounts left) through the expiration path so their queued history rows are cleared and the editor stops polling forever - skip re-queueing on ordinary saves of a post with a pending share: the Classic metabox keeps publish_now checked, and refreshing the history timestamp let stalled entries evade the expiration cutoff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
includes/admin/models/class-rop-queue-model.php:452
- Deleting this registered meta does not leave a neutral value:
rop_publish_now_statushas the REST/meta defaultpending(includes/admin/class-rop-admin.php:2141-2148). For an expired legacy entry or orphan with no history, the editor then skips its only empty-history guard (which requiresdoneatsrc/instant/PostUpdate.js:164) and renders the successful-share UI with an empty history. Retire the request with the established terminal status instead.
delete_post_meta( $post_id, 'rop_publish_now_status' );
Summary
Instant Sharing entries (
rop_publish_now_status = queued) were stored indefinitely with no staleness check. On a site whose cron stalls — common on shared hosting — entries pile up for months; when cron recovers the consumer drains them oldest-modified first with no cutoff, so months-old articles get blasted to every connected account while the post the author just published waits at the back of the line. On the reporting customer's news site (8 Facebook pages) that meant Oct 2025–Feb 2026 articles flooding the pages, and "share on publish" effectively dead.The four defects reported in the issue, all on the publish-now path:
build_queue_publish_now()shared everyqueuedentry regardless of age. Entries now expire against a filterablerop_publish_now_expiration(defaultDAY_IN_SECONDS), using thetimestampalready recorded inrop_publish_now_history; the history row is markedexpiredand logged instead of shared. Entries with no history predate that meta and are treated as stale.get_publish_now_posts()passed'numberposts' => 300toWP_Query, which ignores it (it's aget_posts()arg), so the real batch was the site'sposts_per_page(~10). The drain crawled, which is why fresh posts stayed stuck behind the backlog. Nowposts_per_page.maybe_publish_now()runs onwp_after_insert_postfor any save of a published post and picked up leftoverrop_publish_nowmeta, so routine edits of archive content queued full re-shares. A newmaybe_publish_now_after_insert()wrapper only queues when the post was not already published, unless the Classic Editor metabox explicitly submitspublish_now. Separately,publish_now_attributes()no longer pre-checks the metabox on an already-published post unless a share is genuinely still pending — withinstant_share_defaulton (the plugin default) that pre-check is how the customer's February backlog was created.rop_publish_now_accountswas empty werecontinued without clearingrop_publish_now_status, leaving permanentqueuedrows in the query window. They are now retired through the same expiration path, which also clears theirqueuedhistory rows — the editor treats one of those as an active share and would otherwise poll "Posting to social media…" forever.Two further gaps found in Copilot review and fixed in 79677b0:
rop_publish_now = noon every row it consumed, shrinking the query window. Batch size is nowrop_publish_now_batch_size-filterable.publish_now, re-queued, and overwrote the history timestamp withtime()— letting a long-stalled entry evade the new cutoff. A request that is alreadyqueuedis now left untouched on save.The deliberate re-share paths are untouched: the Block Editor's re-share button posts to
tweet-old-post/v8/share/{id}, which firesrop_publish_now_instant_share→maybe_publish_now( $id, true ), and future→publish still goes throughtransition_post_status(). Both are covered by tests.Distinct from #1101/#1103, which revalidate the recurring queue (
rop_cron_job,build_query_args); nothing there touches the Instant Sharing path. The two changes don't overlap in code.Will affect the visual aspect of the product
NO — the only user-visible change is that a stalled instant share stops showing the indefinite "Posting to social media…" spinner and reports
Expiredin the Sharing History table.Test instructions
Reproducing the bug on
development:DISABLE_WP_CRONwith no real cron.rop_publish_now_status = queued.posts_per_pagebatch per drain, and the new post only goes out after the whole backlog. After it, the stale entries are dropped and only the new post is shared.Automated:
composer test -- --testsuite publish-now— 19 tests. 8 of them fail on unfixeddevelopment, one per defect: stale entries sharing, a backlog delaying the fresh post, the batch capped atposts_per_page, orphans stuck atqueued, an edit re-queueing, and the metabox pre-checking itself on archive content.npm run test:e2e:playwright -- publish-now-backlog— seeds three months-old queue entries, publishes a post through the editor, and asserts the mocked X API only ever receives the fresh one. On unfixed code all four are shared. A second test shares an entry queued seconds ago so the expiry can't regress into dropping legitimate shares.Verified locally against a fresh wp-env:
Test_ROP::test_sdk, which assertsROP_DEBUGis false and so only passes in a production environment — it fails identically on unmodifieddevelopment.publish-now.spec.jsis intermittently red locally on its "Posting to social media…" assertion — the drain retires the entry before the spinner is checked — but passes in isolation and on CI.phpcsclean on all four changed files; PHPStan reports no new errors (107 both before and after, none in the changed methods).Check before Pull Request is ready:
Closes #1102.