Skip to content

fix: expire stale Instant Sharing queue entries instead of flooding accounts - #1104

Open
lucadobrescu wants to merge 3 commits into
developmentfrom
fix/1102-publish-now-queue
Open

fix: expire stale Instant Sharing queue entries instead of flooding accounts#1104
lucadobrescu wants to merge 3 commits into
developmentfrom
fix/1102-publish-now-queue

Conversation

@lucadobrescu

@lucadobrescu lucadobrescu commented Aug 3, 2026

Copy link
Copy Markdown

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:

  1. No staleness guard. build_queue_publish_now() shared every queued entry regardless of age. Entries now expire against a filterable rop_publish_now_expiration (default DAY_IN_SECONDS), using the timestamp already recorded in rop_publish_now_history; the history row is marked expired and logged instead of shared. Entries with no history predate that meta and are treated as stale.
  2. Batch size silently broken. get_publish_now_posts() passed 'numberposts' => 300 to WP_Query, which ignores it (it's a get_posts() arg), so the real batch was the site's posts_per_page (~10). The drain crawled, which is why fresh posts stayed stuck behind the backlog. Now posts_per_page.
  3. Editing an old post re-queued it. maybe_publish_now() runs on wp_after_insert_post for any save of a published post and picked up leftover rop_publish_now meta, so routine edits of archive content queued full re-shares. A new maybe_publish_now_after_insert() wrapper only queues when the post was not already published, unless the Classic Editor metabox explicitly submits publish_now. Separately, publish_now_attributes() no longer pre-checks the metabox on an already-published post unless a share is genuinely still pending — with instant_share_default on (the plugin default) that pre-check is how the customer's February backlog was created.
  4. Orphaned entries. Entries whose rop_publish_now_accounts was empty were continued without clearing rop_publish_now_status, leaving permanent queued rows in the query window. They are now retired through the same expiration path, which also clears their queued history 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:

  1. A backlog larger than one batch could still strand the fresh post. The drain runs on a single event and never scheduled a follow-up, so with more than 300 queued rows the oldest 300 were retired and everything after them waited for an unrelated share event. A full batch now schedules another pass; this terminates because each pass sets rop_publish_now = no on every row it consumed, shrinking the query window. Batch size is now rop_publish_now_batch_size-filterable.
  2. Ordinary saves reset the expiry clock. The Classic metabox stays checked while a share is pending, so every save of such a post submitted publish_now, re-queued, and overwrote the history timestamp with time() — letting a long-stalled entry evade the new cutoff. A request that is already queued is 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 fires rop_publish_now_instant_sharemaybe_publish_now( $id, true ), and future→publish still goes through transition_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 Expired in the Sharing History table.

Test instructions

Reproducing the bug on development:

  • Connect an account, enable Instant Sharing + "By Default", and set DISABLE_WP_CRON with no real cron.
  • Publish or edit a few posts on different days — each gets rop_publish_now_status = queued.
  • Re-enable cron and publish one new post. Before this PR the old entries share first, roughly one posts_per_page batch 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 unfixed development, one per defect: stale entries sharing, a backlog delaying the fresh post, the batch capped at posts_per_page, orphans stuck at queued, an edit re-queueing, and the metabox pre-checking itself on archive content.
  • The other 11 are guards for paths that must keep sharing (publishing a draft, a scheduled post going live, an explicit Classic Editor submit, the Block Editor re-share action) plus the three Copilot findings above, each of which fails on the commit before its fix.
  • 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:

  • PHPUnit 65/66. The one failure is Test_ROP::test_sdk, which asserts ROP_DEBUG is false and so only passes in a production environment — it fails identically on unmodified development.
  • Playwright 7/7 on the full suite. publish-now.spec.js is 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.
  • phpcs clean 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.

lucadobrescu and others added 2 commits August 3, 2026 12:29
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>
@pirate-bot pirate-bot added the pr-checklist-complete The Pull Request checklist is complete. (automatic label) label Aug 3, 2026
@pirate-bot

pirate-bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Plugin build for 79677b0 is ready 🛎️!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment on lines +374 to +378
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;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, fixed in 79677b0. Verified against src/instant/PostUpdate.jsisQueued = 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.

Comment thread includes/admin/class-rop-admin.php Outdated
Comment on lines +931 to +932
if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status && empty( $_POST['publish_now'] ) ) {
return;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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

@lucadobrescu lucadobrescu self-assigned this Aug 3, 2026
- 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>

Copilot AI left a comment

Copy link
Copy Markdown

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 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_status has the REST/meta default pending (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 requires done at src/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' );

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

Labels

pr-checklist-complete The Pull Request checklist is complete. (automatic label)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants