Skip to content

Fixed scheduler retrying publish jobs when there is nothing to publish - #29306

Merged
allouis merged 1 commit into
mainfrom
fix/scheduler-noop-publish-2xx
Jul 16, 2026
Merged

Fixed scheduler retrying publish jobs when there is nothing to publish#29306
allouis merged 1 commit into
mainfrom
fix/scheduler-noop-publish-2xx

Conversation

@allouis

@allouis allouis commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Ghost's scheduler pings the publish endpoint (PUT /schedules/:resource/:id) when a scheduled post or page is due. Previously this endpoint returned a 404 in two situations where there is genuinely nothing to publish: when it fires ahead of the scheduled time, and when the target resource no longer exists. Schedulers treat any non-2xx response as a failure and retry it — Ghost's production remote scheduler retries any non-2xx up to a limit — so each of these benign cases turned into a retry storm plus error logs that drown out real failures.

This changes both cases to a 2xx no-op so the job is treated as done:

  • Fired ahead of the scheduled time. When a post is rescheduled to a later time, a scheduler that can't invalidate its original job still fires at the old, earlier time. There is nothing to publish yet, so the controller now returns an empty resource list instead of throwing NotFound.
  • Resource no longer exists. The scheduler holds a job for a post or page that has since been deleted. Ghost's permission stage loads the resource by id and 404s for a missing one before the controller runs, so this case needed a custom permissions handler on the endpoint. The handler runs the normal post:publish check but tolerates a missing resource, letting the controller return an empty list.

Firing well after the scheduled time without a force flag still errors — that's a genuinely dropped publish worth surfacing, and its behavior is unchanged.

Security

The custom permissions handler swallows only NotFoundError (resource missing). NoPermissionError and every other error still propagate, so permission enforcement on resources that DO exist is unchanged: a caller without post:publish hitting an existing post still gets a 403 (covered by a regression test). No new information is exposed either — an authenticated caller could already distinguish missing (404) from unauthorized-but-existing (403) today. This only changes the missing case's status to 200, and it remains a pure no-op with no side effects.

Testing

Regression tests were added and updated in the legacy schedules API suite: fired-ahead returns 200 with an empty body; a deleted resource returns 200 with an empty body; past-without-force still returns 404; and the existing "no access" test (non-scheduler key on an existing post → 403) guards that the permissions handler only swallows NotFound, never a permission error.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Scheduler publishing now returns a no-op result when a job fires before its scheduled time or the resource no longer exists. The schedules endpoint handles missing resources with empty responses, centralizes cache-invalidation headers, and continues to propagate non-NotFoundError failures. API tests cover early and deleted-resource no-ops, late publishing without force as a 404, and existing resources without permission as a 403.

Suggested labels: preview

Suggested reviewers: cobbspur

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: preventing scheduler retries when publish jobs have nothing to publish.
Description check ✅ Passed The description is directly related to the endpoint and scheduler behavior changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/scheduler-noop-publish-2xx

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 14, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 1e29463

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 2m 52s View ↗
nx run ghost:test:integration ✅ Succeeded 2m 58s View ↗
nx run ghost:test:legacy ✅ Succeeded 3m 12s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 37s View ↗
nx run @tryghost/admin:build ✅ Succeeded 2m 23s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 31s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 18s View ↗
nx run-many -t lint -p ghost,ghost-monorepo ✅ Succeeded 22s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-16 10:04:02 UTC

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
ghost/core/core/server/services/posts/post-scheduling.js (1)

34-51: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Solid fix, but the TOCTOU window between read() and edit() isn't covered.

The try/catch only wraps the initial api[resourceType].read(...) call. If the resource is deleted (or unscheduled) in the window between this read and the edit() call further down (line 65), edit() can itself throw a NotFoundError that is not caught here and will propagate as an error — reintroducing exactly the noisy-retry scenario this PR is meant to eliminate, just with a narrower window.

Consider extending the no-op conversion to the edit() call as well (e.g. wrap both calls, or wrap the whole publish attempt).

♻️ Suggested approach
     const editedResource = {};
     editedResource[resourceType] = [{
         status: 'published',
         updated_at: moment(preScheduledResource.updated_at).toISOString(true)
     }];
 
-    const editResult = await api[resourceType].edit(
-        editedResource,
-        _.pick(options, ['context', 'id', 'transacting', 'forUpdate'])
-    );
+    let editResult;
+    try {
+        editResult = await api[resourceType].edit(
+            editedResource,
+            _.pick(options, ['context', 'id', 'transacting', 'forUpdate'])
+        );
+    } catch (err) {
+        if (errors.utils.isGhostError(err) && err.errorType === 'NotFoundError') {
+            return NO_OP;
+        }
+        throw err;
+    }
     const scheduledResource = editResult[resourceType][0];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/posts/post-scheduling.js` around lines 34 -
51, Extend the NotFoundError-to-NO_OP handling in the post-scheduling flow to
include the later api[resourceType].edit() call, covering resources deleted or
unscheduled after the initial read. Wrap the read-and-edit publish attempt in
the existing error handling while preserving propagation of all
non-NotFoundError failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ghost/core/core/server/services/posts/post-scheduling.js`:
- Around line 34-51: Extend the NotFoundError-to-NO_OP handling in the
post-scheduling flow to include the later api[resourceType].edit() call,
covering resources deleted or unscheduled after the initial read. Wrap the
read-and-edit publish attempt in the existing error handling while preserving
propagation of all non-NotFoundError failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c7389256-de70-4f5a-a434-2c238b5f2ddf

📥 Commits

Reviewing files that changed from the base of the PR and between 557244f and a848b33.

📒 Files selected for processing (3)
  • ghost/core/core/server/api/endpoints/schedules.js
  • ghost/core/core/server/services/posts/post-scheduling.js
  • ghost/core/test/legacy/api/admin/schedules.test.js

@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch 2 times, most recently from 756efc4 to c2cc976 Compare July 14, 2026 07:44
@allouis allouis changed the title Fixed scheduler retrying publish jobs when there is nothing to publish Fixed scheduler retrying publish jobs that fire ahead of the scheduled time Jul 14, 2026
@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch from c2cc976 to 5f023b3 Compare July 16, 2026 03:08
@allouis
allouis marked this pull request as ready for review July 16, 2026 03:39
@allouis
allouis requested a review from cobbspur July 16, 2026 03:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/legacy/api/admin/schedules.test.js (1)

155-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for deleted and unscheduled posts.

These tests cover early and late scheduler timing, but the PR objective also requires deleted and no-longer-scheduled posts to return 200 with an empty posts array.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/test/legacy/api/admin/schedules.test.js` around lines 155 - 173,
Add tests in the schedule firing suite alongside the existing timing cases for
deleted posts and posts that are no longer scheduled. Exercise the same PUT
endpoint and assert a 200 response with a JSON body whose posts array is empty,
preserving the existing response-header assertions where applicable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ghost/core/core/server/services/posts/post-scheduling.js`:
- Around line 12-16: Update the scheduling flow around api[resourceType].read()
to catch NotFoundError and return the existing NO_OP tuple when the scheduled
resource was deleted or is no longer scheduled. Preserve propagation of all
other errors and leave normal resource handling unchanged.

---

Nitpick comments:
In `@ghost/core/test/legacy/api/admin/schedules.test.js`:
- Around line 155-173: Add tests in the schedule firing suite alongside the
existing timing cases for deleted posts and posts that are no longer scheduled.
Exercise the same PUT endpoint and assert a 200 response with a JSON body whose
posts array is empty, preserving the existing response-header assertions where
applicable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c177ed22-13bf-4790-9176-670938dbc472

📥 Commits

Reviewing files that changed from the base of the PR and between a848b33 and 5f023b3.

📒 Files selected for processing (3)
  • ghost/core/core/server/api/endpoints/schedules.js
  • ghost/core/core/server/services/posts/post-scheduling.js
  • ghost/core/test/legacy/api/admin/schedules.test.js

Comment thread ghost/core/core/server/services/posts/post-scheduling.js Outdated
@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch from 5f023b3 to 57a1bcd Compare July 16, 2026 04:58
@allouis allouis changed the title Fixed scheduler retrying publish jobs that fire ahead of the scheduled time Fixed scheduler retrying publish jobs when there is nothing to publish Jul 16, 2026
@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch from 57a1bcd to 7a4177b Compare July 16, 2026 05:07
@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 32.25806% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.97%. Comparing base (13fe101) to head (11eb31b).
⚠️ Report is 5742 commits behind head on main.

Files with missing lines Patch % Lines
ghost/core/core/server/api/endpoints/schedules.js 30.55% 25 Missing ⚠️
...core/core/server/services/posts/post-scheduling.js 34.61% 17 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #29306      +/-   ##
==========================================
+ Coverage   73.76%   73.97%   +0.20%     
==========================================
  Files        1552     1572      +20     
  Lines      134208   137437    +3229     
  Branches    16112    16607     +495     
==========================================
+ Hits        99004   101663    +2659     
- Misses      34195    34741     +546     
- Partials     1009     1033      +24     
Flag Coverage Δ
e2e-tests 76.03% <32.25%> (+0.16%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch 2 times, most recently from 295c3b9 to 1e29463 Compare July 16, 2026 09:51
The schedules publish endpoint returned a 404 when a job fired ahead of
the scheduled time (because the post had been rescheduled to a later
time) or when the post or page had since been deleted. External
schedulers treat any non-2xx response as a failure and retry it, so
these benign no-ops caused retry storms and noisy error logs that drown
out real failures.

Both cases now return a 2xx no-op so the job is treated as done. Firing
well after the scheduled time without a force flag still errors, since
that is a genuinely dropped publish worth surfacing.

The deleted case needs a custom permissions handler, because the
permission stage loads the resource and 404s a missing one before the
controller runs. It swallows only NotFoundError, so permission
enforcement on resources that still exist is unchanged.
@allouis
allouis force-pushed the fix/scheduler-noop-publish-2xx branch from 1e29463 to 11eb31b Compare July 16, 2026 09:52
@allouis
allouis enabled auto-merge (rebase) July 16, 2026 09:54
@allouis
allouis merged commit 981d344 into main Jul 16, 2026
43 checks passed
@allouis
allouis deleted the fix/scheduler-noop-publish-2xx branch July 16, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant