Make WordPress Core

Opened 4 weeks ago

Last modified 3 weeks ago

#65663 new feature request

Serve an overdue scheduled post instead of a 404 when its permalink is requested

Reported by: heera Owned by:
Priority: normal Milestone: Awaiting Review
Component: Posts, Post Types Version: trunk
Severity: normal Keywords: 2nd-opinion has-patch
Cc: Focuses:

Description

Problem

WP-Cron only runs when traffic arrives, so a scheduled post can pass its
publish time while still in future status. Core already acknowledges this
state in the admin — the posts list table prints a red error label
(Missed schedule, wp-admin/includes/class-wp-posts-list-table.php) — but
the reader-facing half is untreated: the post's permalink returns a 404 at
the exact moment the link is most likely being shared (launch announcements,
newsletters, social posts scheduled to match the publish time).

Even on sites with correctly configured cron there is a first-visitor race:
the first request to arrive after the due time spawns cron non-blocking,
so that same request is served a 404 while cron publishes the post roughly a
second later. No cron configuration closes that gap; only handling it in the
request itself does.

Proposal

In WP::handle_404(), before set_404(): when the main query result set is
empty and the requested name/pagename resolves to a post in future
status whose post_date is in the past, publish it (wp_publish_post()),
re-run the main query, and serve the post as a normal 200.

Cost and safety controls, all cheap:

  • A cached "next scheduled post time" (invalidated from transition_post_status whenever a post enters or leaves future) short- circuits the check, so 404 floods from scanners never touch the posts table.
  • A lock around the publish (the existing cron lock, or a short-lived transient) prevents duplicate wp_publish_post() hook fires when two first-visitors race.
  • spawn_cron() after a heal flushes any other overdue events the normal way.

The scheduled slug is already reserved at scheduling time, so the lookup only
matches content whose publication was explicitly authored and authorized.

Prior art

  • #9388 and #39340 approach the same failure from the cron side (making the event fire reliably), and #49520 identifies the cron-array race that loses publish_future_post events on busy sites. #63599 (2025) reports missed schedules even with DISABLE_WP_CRON and a real system cron. This proposal is complementary and narrower: it treats the visitor-facing symptom at the last possible moment, and only on requests that are already about to 404 — i.e. zero cost for every normal page view.
  • Core has accepted a heal-on-access catch-up once already: #42457 made an overdue scheduled Customizer changeset publish when the Customizer is opened after its date has passed, rather than staying stuck. This proposal applies the same idea to the reader-facing permalink.
  • The plugin ecosystem has carried this for 15+ years ("Missed Scheduled Post Publisher", "Scheduled Post Trigger", the retired WP Missed Schedule, …) with six-figure combined installs — evidence of sustained demand. Those plugins check for overdue posts on every page load; scoping the check to would-be 404s is strictly cheaper than what a large install base already runs today.
  • The 404-bypass filter this can build on exists since #10722, and a userland implementation hooked on pre_handle_404 is running in production; detailed rationale, the failure modes, and the implementation are written up in Missed Schedule: a self-healing fix for WP-Cron. Happy to adapt it into a core patch. One note for anyone prototyping the same way: pre_handle_404 fires before set_404(), so is_404() is always false inside it — the empty result set is the correct trigger.

Anticipated objections

"GETs shouldn't mutate state." WP-Cron has published posts as a side
effect of GET traffic since it exists; this is the same trust model. The
state change was authored, reviewed and scheduled by a user with
publish_posts — the request merely executes an instruction that is already
overdue.

"Configure real cron instead." That fixes punctuality and should remain
the primary advice, but (a) it cannot close the first-visitor race described
above, (b) #63599 shows misses happen even with real system cron, and
(c) core added the "Missed schedule" admin error precisely because imperfect
cron setups are endemic. This completes that feature: core currently tells
the author about the failure while still showing readers a 404 for content
everyone agreed should be live.

"Full-page caches may store the 404." Then the request never reaches
PHP and behavior degrades to exactly what happens today — no worse. (Worth a
line in the docs; edge-cached 404s also defeat cron-published posts' first
minutes in the same way.)

Change History (6)

This ticket was mentioned in Slack in #core by sheikh_heera. View the logs.


4 weeks ago

#2 @westonruter
3 weeks ago

Re: Slack message:

I investigated a Missed Schedule case that turned out to be more subtle than WP-Cron starvation — even with real system cron there's a first-visitor race where the request that spawns cron gets served a 404 for a post that's due. I prototyped a pre_handle_404 safety net that publishes an overdue scheduled post on first request (with a transient short-circuit so 404 floods never touch the posts table), and found that CDN-cached 404s can bypass any PHP-based recovery entirely.

How would the user have the URL for the scheduled post which isn't published yet?

#3 @heera
3 weeks ago

Replying to westonruter:

How would the user have the URL for the scheduled post which isn't published yet?

The permalink is fixed at scheduling time — the editor displays it and the slug is reserved — so in practice it circulates ahead of publication through the author's own launch tooling:

  • Scheduled announcements: a tweet / newsletter / social post queued to fire at the same minute as the post, with the permalink pasted in advance. Those clicks land right at the due time — exactly when the first-visitor race window is open — so the traffic and the failure are correlated by design, not coincidence.
  • A previously published post whose date is moved back into the future keeps its slug, so existing backlinks, social shares and search-index entries keep pointing at it until cron catches up.
  • The author (or a teammate) checking the launch at publish time.

And when nobody has the URL, the check never executes — it's scoped to requests that are already about to 404, so the "nobody knows the link yet" case costs nothing beyond today's 404 path.

This ticket was mentioned in PR #12600 on WordPress/wordpress-develop by @vedantere.


3 weeks ago
#4

  • Keywords has-patch added; needs-patch removed

https://core.trac.wordpress.org/ticket/65663

Proof of concept for the proposal in the ticket above, posted for discussion rather than as a finished patch. Opening this as one data point alongside the ticket reporter's own working implementation, not to replace it.

## What this does

WP-Cron only runs on incoming traffic, so a scheduled post can pass its publish time while still sitting in future status, and its permalink 404s until some later request happens to trigger cron.

In WP::handle_404(), when the main query finds nothing and the requested name/pagename matches a post in future status whose post_date has already passed, this publishes it via check_and_publish_future_post() (the same function the publish_future_post cron hook uses, so the future/time checks are re-validated rather than duplicated) and re-runs the query instead of serving a 404.

Cost controls:

  • A cached "next scheduled post" time (wp_next_scheduled_post_is_due()), invalidated via transition_post_status whenever a post enters or leaves future, short-circuits the check so ordinary 404s and scanner traffic never reach the posts table.
  • A short-lived transient lock narrows (does not eliminate) the double-publish race between two near-simultaneous first visitors.
  • spawn_cron() runs afterward to flush any other overdue events normally.

## Known open questions (not resolved by this PR)

  • Whether this should be default-on or opt-in behind a filter, given it changes 404 behavior for every site.
  • The transient lock is best-effort, not atomic.
  • Interaction with full-page caches that deliberately cache 404 responses.

See the ticket discussion for more context and the original proposal.

#5 @heera
3 weeks ago

Thanks for the PoC, @vedant-ere — good to have a second implementation to compare against. A few notes from running my own version of this in production, most useful first.

On the double-publish race: the get_transient/set_transient pair narrows it but doesn't close it — two simultaneous first-visitors can both pass the check before either sets the lock, and when that happens publish_post, pingbacks and notifications fire twice. My own production version doesn't lock at all; it leans on the window being small (a future slug isn't in sitemaps or feeds until it publishes, so concurrent first-hits on the exact permalink are rare). For core-grade concurrency an atomic claim is the direction I'd point at:

$claimed = $wpdb->query( $wpdb->prepare(
    "UPDATE $wpdb->posts SET post_status = 'publish' WHERE ID = %d AND post_status = 'future'",
    $post->ID
) );

with one wrinkle to solve first: once the status is flipped out-of-band like that, wp_publish_post()/check_and_publish_future_post() see it as already publish and won't fire the future_to_publish transition, so the winning request still has to run the publish side effects exactly once by another route. Worth sorting before this is committable — but a cleaner base than an advisory transient.

Full-page caches that cache 404s are the real-world limiter, and it's worth naming in the ticket. The heal only runs when PHP reaches handle_404(). A CDN serving a cached 404 never gets there — and if the edge cached the 404 shortly before publish time, it keeps serving it until the TTL expires or the cache is purged. On my own site Cloudflare caches 404s for ~4 hours, so this is a clean win for origin-served and short/no-404-TTL sites, and neutral-until-purge for long-404-TTL CDNs. It complements a cron-kick + purge rather than replacing it — not an argument against the patch, just a boundary the ticket should state.

On default-on vs opt-in: I'd lean default-on, precisely because of the wp_next_scheduled_post_is_due() guard. On a site with no scheduled posts the transient caches 0 and the hot path costs nothing, so "changes 404 behavior for every site" is really about the CDN case above, not performance. A filter to disable is cheap insurance; I wouldn't gate it behind opt-in by default.

One coverage question: the branch is name (non-hierarchical) vs pagename (hierarchical, via get_page_by_path()). Hierarchical custom post types don't always populate pagename depending on their rewrite — do scheduled hierarchical-CPT permalinks fall through both branches? Worth a test case either way.

Reusing check_and_publish_future_post() is the right call — its re-validation makes your pre-check belt-and-suspenders rather than the only gate. I'll follow up with my own production version — a pre_handle_404 drop-in, different hook point, same goal — as a reference.

Last edited 3 weeks ago by heera (previous) (diff)

#6 @heera
3 weeks ago

As promised — the production version I run. It's a self-contained drop-in on the pre_handle_404 filter rather than a core change, so it needs no patch: drop it in a theme's inc/ (or a small plugin) and it works unchanged. Posting it as the reporter's reference alongside @vedant-ere's core PoC — different integration point, same goal.

The file header covers the design; three things worth calling out against the discussion above:

  • It keys on an empty main-query result set, not is_404() — which is always false this early in handle_404(), so a ! is_404() guard would be dead code. That was the first trap I hit.
  • The transient short-circuit (schedule_is_overdue()) means a 404-scanner storm costs one cache read each, never a posts-table query, until a scheduled post is actually due. The cached value is stored as a string so '0' (nothing scheduled) survives object caches that conflate falsy with a miss.
  • As in my note above, it doesn't lock — it leans on the pre-publish window being small; the atomic claim is where I'd take it for core.

One caveat lives outside PHP: a full-page cache that stores 404 HTML defeats any version of this, since the request never reaches PHP (my Cloudflare note above).

<?php
/**
 * Missed-schedule healer — publish an overdue scheduled post on arrival
 * instead of serving a 404.
 *
 * WP-Cron only runs when traffic happens to arrive, so a scheduled post whose
 * time has passed can sit in `future` status while its permalink 404s — worst
 * exactly when a launch link is being shared. This intercepts the would-be
 * 404: when the requested slug belongs to an overdue `future` post, the post
 * is published on the spot, the main query re-runs, and the visitor gets the
 * post. It complements real cron (which fixes punctuality); this rescues the
 * visitor who arrives in the gap.
 *
 * Portability: this file is a self-contained drop-in. It touches no theme
 * functions, constants or mods — only core APIs — so it works unchanged in
 * any theme, classic or block: copy the file in and require it. The
 * class_exists guard means duplicate copies (parent + child theme, or a
 * future plugin version) never redeclare — the first one loaded wins, and
 * the $preempt guard plus the `future`-only lookup make even a second
 * *hooked* healer a harmless no-op.
 *
 * The trap this deliberately avoids: `pre_handle_404` fires at the TOP of
 * WP::handle_404(), BEFORE set_404() — so is_404() is ALWAYS false inside the
 * filter, and any `! is_404()` guard turns the whole handler into dead code.
 * The real "this is about to 404" signal is an empty main-query result set.
 *
 * Caveats honoured here:
 * - Cost: 404-scanner storms are cheap — a transient caches the next
 *   scheduled post's time, so when nothing is due the handler bails without
 *   touching the posts table.
 * - Scope: only the visited slug is healed, so spawn_cron() is pinged
 *   (non-blocking) to flush any other overdue events too.
 * - Permalinks: the slug comes from the `name`/`pagename` query vars, which
 *   pretty permalinks populate; under plain `?p=` permalinks the handler
 *   stands down (never heals, never breaks).
 *
 * One caveat lives outside PHP: a full-page cache that stores 404 HTML
 * defeats this, because the request never reaches PHP. On Cloudflare, give
 * the caching rule a Status-code Edge TTL of "404 → No store".
 *
 * @package TheAlpha
 */

if ( ! defined( 'ABSPATH' ) ) {
        exit;
}

if ( ! class_exists( 'Missed_Schedule_Healer' ) ) {

        class Missed_Schedule_Healer {

                /**
                 * Transient caching the GMT timestamp of the soonest scheduled post as
                 * a string ('0' = nothing scheduled), so a burst of 404s costs one
                 * cache read each instead of a posts-table query each. Deliberately
                 * generic (no theme prefix): the cached value is a site fact, not a
                 * theme fact, so every copy of this class on a site shares it safely.
                 */
                const NEXT_DUE_KEY = 'missed_schedule_next_due_gmt';

                /**
                 * Hook up the healer. Called once from functions.php.
                 */
                public static function boot() {
                        add_filter( 'pre_handle_404', array( __CLASS__, 'heal_missed_schedule' ), 10, 2 );
                        // Any schedule change (created, rescheduled, published — including
                        // by this healer — or unscheduled) invalidates the next-due cache.
                        add_action( 'transition_post_status', array( __CLASS__, 'flush_next_due' ), 10, 2 );
                }

                /**
                 * pre_handle_404 handler: publish the overdue scheduled post behind a
                 * would-be 404 and serve it.
                 *
                 * @param bool     $preempt  Whether another handler already claimed the request.
                 * @param WP_Query $wp_query The main query.
                 * @return bool True when the post was published and the re-run query found
                 *              it (WP then skips the 404 entirely); $preempt otherwise.
                 */
                public static function heal_missed_schedule( $preempt, $wp_query ) {
                        if ( false !== $preempt ) {
                                return $preempt;
                        }

                        // NOT is_404() — that's still false here (set_404() runs after this
                        // filter). An empty result set is the real signal.
                        if ( ! empty( $wp_query->posts ) ) {
                                return $preempt;
                        }

                        $slug = self::requested_slug( $wp_query );
                        if ( '' === $slug ) {
                                return $preempt;
                        }

                        // Cheap short-circuit: no scheduled post is due, so this 404 can't
                        // be a missed schedule — bail before the targeted query below.
                        if ( ! self::schedule_is_overdue() ) {
                                return $preempt;
                        }

                        $overdue = get_posts( array(
                                'name'           => $slug,
                                'post_type'      => 'any',
                                'post_status'    => 'future',
                                'posts_per_page' => 1,
                                'date_query'     => array(
                                        array(
                                                'before'    => current_time( 'mysql' ),
                                                'inclusive' => true,
                                        ),
                                ),
                        ) );
                        if ( empty( $overdue ) ) {
                                return $preempt;
                        }

                        wp_publish_post( $overdue[0] );

                        // Non-blocking cron ping so any OTHER overdue events (more posts,
                        // purges, emails) flush too — this handler only heals the visited
                        // slug.
                        spawn_cron();

                        // Re-run the main query against the now-published post; on success
                        // WP serves it as a normal 200 single view.
                        $wp_query->query( $wp_query->query_vars );

                        return ! empty( $wp_query->posts );
                }

                /**
                 * The slug the request asked for, from the parsed query vars.
                 *
                 * @param WP_Query $wp_query The main query.
                 * @return string Slug, or '' when the request isn't slug-shaped (home,
                 *                archives, feeds — nothing a healer could publish).
                 */
                protected static function requested_slug( $wp_query ) {
                        if ( ! empty( $wp_query->query_vars['name'] ) ) {
                                return (string) $wp_query->query_vars['name'];
                        }
                        // Hierarchical pages arrive as a parent/child path; the leaf is the
                        // slug.
                        if ( ! empty( $wp_query->query_vars['pagename'] ) ) {
                                $parts = explode( '/', trim( (string) $wp_query->query_vars['pagename'], '/' ) );
                                return (string) end( $parts );
                        }
                        return '';
                }

                /**
                 * Whether any scheduled post is past due, via the cached next-due time.
                 *
                 * @return bool
                 */
                protected static function schedule_is_overdue() {
                        $next = get_transient( self::NEXT_DUE_KEY );
                        if ( false === $next ) {
                                $soonest = get_posts( array(
                                        'post_type'      => 'any',
                                        'post_status'    => 'future',
                                        'posts_per_page' => 1,
                                        'orderby'        => 'date',
                                        'order'          => 'ASC',
                                ) );
                                // Stored as a string so '0' (nothing scheduled) survives object
                                // caches that conflate falsy values with a cache miss.
                                $next = $soonest ? (string) get_post_time( 'U', true, $soonest[0] ) : '0';
                                set_transient( self::NEXT_DUE_KEY, $next, DAY_IN_SECONDS );
                        }
                        $next = (int) $next;
                        return $next > 0 && $next <= time();
                }

                /**
                 * Drop the next-due cache whenever a post enters or leaves `future`.
                 *
                 * @param string $new_status New post status.
                 * @param string $old_status Old post status.
                 */
                public static function flush_next_due( $new_status, $old_status ) {
                        if ( 'future' === $new_status || 'future' === $old_status ) {
                                delete_transient( self::NEXT_DUE_KEY );
                        }
                }
        }

        Missed_Schedule_Healer::boot();
}

Note: See TracTickets for help on using tickets.