Skip to content

Migrated send-gift-reminders to the class-based jobs service - #30730

Merged
vershwal merged 4 commits into
mainfrom
migrate-send-gift-reminders
Sep 15, 2026
Merged

vershwal merged 4 commits into
mainfrom
migrate-send-gift-reminders

Conversation

@vershwal

@vershwal vershwal commented Sep 14, 2026 •

Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/HKG-1976/migrate-send-gift-reminders-to-the-durable-jobs-interface

What

Migrates the daily send-gift-reminders fallback job from its legacy Bree worker to the class-based jobs service, following the clean-gifts migration (HKG-1975), which had the same shape: a worker thread that did no work itself, posted a domain event back to the main process and reported "done" before a single reminder was sent. This phase only moves the job behind the new interface; the backend stays in-memory and no durable queue is introduced.

  • SendGiftRemindersJob (gifts/jobs/send-gift-reminders-job.ts): stable type send-gift-reminders, no payload.
  • Handler: registered in register-job-handlers.ts beside clean-gifts, a single line delegating to giftService.processReminders() on the already-injected giftService. A failed poll propagates so the jobs service reports a failure rather than an idle completion.
  • Summary log in GiftService.processReminders(): the jobs service's lifecycle log carries no counts, so the poll itself now logs a structured send_gift_reminders.completed event (reminded_count, skipped_count, failed_count, duration_ms) with the line [Background Job] send-gift-reminders processed reminders: N sent, M not due, K rejected. Because the log lives where the work happens, it is emitted by every call to processReminders() — the daily job and the exact per-gift scheduler alike. The early return for an empty poll was removed so a run with nothing due still logs a zero-count summary.
  • Scheduling: scheduleGiftReminderJob(jobsService) in gifts/jobs/index.js mirrors scheduleGiftCleanupJob: same randomOffPeakDailyCron() (a random second/minute/hour between 00:00 and 05:59), the same once-per-process and test-environment guards, and the verbatim [Background Job] send-gift-reminders scheduled at <cron> log line. It is called from initBackgroundServices in boot.js (inside try/catch, above activitypub.init()) because the gifts service initialises before the jobs service is started (its backend rejects recurring registrations until start(), which boot runs after initServices) — exactly where clean-gifts scheduling lives.
  • Legacy path removed in the same commit (no double registration): the worker send-gift-reminders-job.js, the scheduleJob()/jobManager/path plumbing in gifts/jobs/index.js, and the jobs.scheduleGiftReminderJob() call at the end of gifts/index.ts#init. The worker and the new class share a basename, so they must never coexist in a commit (an extensionless require from JS resolves the .js first, and build:tsc emits in place).
  • Untouched: the reminder selection and sending logic — the repository query and eligibility window, sendReminderForGift() (forUpdate row locks, the consumes_soon_reminder_sent_at marker committed before the email, missing / email_disabled member skips), per-gift error containment in processReminders() (only its summary log and empty-poll early return changed, above), the email service, and the exact per-gift scheduler path (SignedFlushScheduler → PUT /gifts/flush_reminders/ → StartGiftReminderFlushEvent → the subscription in gifts/index.ts, whose own log lines are unchanged). Both paths still converge on the same locked, marker-guarded code, so neither can send a reminder the other already sent.

Test approach

  • Unit: the job class (type, instanceof Job, empty serialisable payload), the scheduling guard (not scheduled under NODE_ENV=test*; scheduled exactly once outside it with an off-peak cron, the verbatim log line, and no legacy addJob call), the handler registration (delegates to giftService.processReminders() and propagates its failure), and GiftService.processReminders() (logs the structured completion event with its counts; propagates a failed poll and logs no completion).
  • Integration (test/integration/services/gifts/send-gift-reminders.test.ts): dispatches the job through the real jobs service against a booted Ghost and waits for the job.completed lifecycle event before asserting (the marker is committed before the email is sent, so polling the marker alone would race the mail mock): an eligible redeemed gift is marked once, its redeemer gets exactly one reminder with the expected subject, and the summary reports 1 sent; a second run after the first completes sends nothing more, leaves the marker untouched and logs a zero-count summary; a failed poll is logged as [Background Job] send-gift-reminders failed after … with no completion event and no summary.
  • The existing processReminders() integration suite (unchanged apart from its header comment, which referred to the deleted worker) and the flush_reminders endpoint tests still pass.

Behaviour parity

Everything customer-visible is preserved: which gifts are selected, when the daily fallback runs, the locking and marker semantics, who gets emailed, and at-most-once delivery (the in-memory backend runs a tick once with no retries, like Bree ran the worker once).

Byte-identical log lines: [Background Job] send-gift-reminders scheduled at <cron>, … started, and … failed after Xms (the jobs service emits exactly the lines the legacy subscription emitted).

Accepted deltas (the same set every prior migration documented, plus the summary log):

  • The job now runs the poll in-process and completes when it finishes; [Background Job] send-gift-reminders completed in Xms plus the structured job.completed event replace the legacy subscription's completed in Xms: N sent, M not due, K rejected line for the daily path, with the counts moving to send_gift_reminders.completed. The Bree/JobManager lines (Adding offloaded job…, Scheduling job…, dispatched to main process, Worker for job "send-gift-reminders" online) disappear. Verified against the pro-infra Elastic alert rules: no rule matches any of these strings.
  • The exact per-gift scheduler path now also logs the send_gift_reminders.completed summary (including when nothing was due), in addition to its existing, unchanged started / completed in Xms: … lines, so its counts appear twice per flush. Nothing is removed from that path.
  • A failed poll (only a failed findPendingReminder query can escape processReminders) now also reaches Sentry with a job_type tag and the backend's delivery failed line; the legacy path only logged.
  • Scheduling registers from initBackgroundServices (after boot, inside try/catch) instead of synchronously and unguarded inside giftService.init(), so a registration failure degrades to an error log instead of failing boot.
  • A tick is enqueued on the backend's shared default lane (concurrency 3) instead of starting processReminders() immediately, so a poll can wait behind other default-lane work — timing only.
  • Shutdown drains an in-flight run within server:shutdownTimeout and drops a queued-but-not-started tick; the legacy worker only signalled and exited.
  • The legacy worker's started and 'done' messages each made JobManager read the jobs table for a row named send-gift-reminders, twice per tick; no such row ever existed (only one-off jobs get one), so the reads were inert and simply disappear.

Telling the two paths apart in logs: send_gift_reminders.completed, … started and … failed after are emitted by both. The daily job is identified by the jobs service's job.completed event with job_type: send-gift-reminders (and its count-less completed in Xms line); the exact scheduler by its completed in Xms: N sent, M not due, K rejected line.

Production verification

Per the issue, the migrated job must be verified on staging and then production during its natural 00:00–05:59 window using the checklist in HKG-1976 (one migrated recurring registration and no legacy cron, exactly one reminder for a naturally eligible gift, no sends for already-marked / refunded / consumed / missing-member / email-disabled cases, the exact scheduler skipping an already-reminded gift, and the counts reconciled with provider logs).

ref https://linear.app/ghost/issue/HKG-1976

- The daily reminder job is moving to the class-based jobs service, whose
  handlers delegate to an injected service method, the shape clean-gifts
  already uses with cleanup()
- The jobs service's lifecycle log carries no counts, so the sent, not
  due and rejected summary the legacy path logged needs a home of its own
- A failed poll propagates so the jobs service reports a failure instead
  of an idle completion; nothing calls the method yet, so behaviour is
  unchanged
ref https://linear.app/ghost/issue/HKG-1976

- The legacy Bree worker did no work: it posted a domain event back to
  the main thread and reported done before a single reminder was sent,
  so job success and duration were meaningless
- The worker and its legacy registration go in the same commit as the
  new class because the two files share a basename and the job must
  never be registered in both systems
- Scheduling moves to initBackgroundServices because the gifts service
  initialises before the jobs service is started and its backend rejects
  recurring registrations until then; the random 00:00-05:59 daily cron
  and its guards are unchanged
- The exact per-gift scheduler is deliberately untouched; both paths
  still converge on the row-locked, marker-guarded reminder code
@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Advanced

Run ID: 3e214e8f-b3c8-4e3f-953c-914238bd94c8

📥 Commits

Reviewing files that changed from the base of the PR and between 2082cdc and 101a509.

📒 Files selected for processing (5)
  • ghost/core/core/boot.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/jobs-service/register-job-handlers.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (18)
  • GitHub Check: Acceptance tests (Node 22.23.1, mysql8)
  • GitHub Check: Legacy tests (Node 24.20.0, mysql8)
  • GitHub Check: Unit tests (Node 24.20.0)
  • GitHub Check: Typecheck
  • GitHub Check: Build Admin
  • GitHub Check: Legacy tests (Node 22.23.1, mysql8)
  • GitHub Check: Acceptance tests (Node 24.20.0, mysql8)
  • GitHub Check: Build E2E Public App Assets
  • GitHub Check: Build Docker Images
  • GitHub Check: Stripe fixture checks
  • GitHub Check: Unit tests (Node 22.23.1)
  • GitHub Check: Check app version bump
  • GitHub Check: Lint
  • GitHub Check: Check migration integrity
  • GitHub Check: i18n
  • GitHub Check: Lint docs
  • GitHub Check: Detect Tinybird changes
  • GitHub Check: Analyze (javascript-typescript)
🧰 Additional context used
📓 Path-based instructions (7)
Review new or changed service boundaries for explicit dependency ownership, deterministic/idempotent initialisation, boot ordering, transaction and event semantics, cache coherence, and restart/multi-instance safety.

⚙️ CodeRabbit configuration file

Files:

  • ghost/core/core/server/services/jobs-service/register-job-handlers.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
Review whether tests prove changed behaviour, meaningful error/edge paths, and externally observable contracts without coupling to implementation details.

⚙️ CodeRabbit configuration file

Files:

  • ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
New source files must be TypeScript: flag new JS files as a required change unless exempt (DB migrations, apps/ember-admin/, tool/config files, scripts/, docker/, generated code).

⚙️ CodeRabbit configuration file

Files:

  • ghost/core/core/boot.js
Review lens: "where does this data become trusted?" Boundary data (HTTP input, external API/SDK responses, env/config, DB/filesystem reads, queue/webhook/event payloads) is `unknown` until validated — Zod by default.

⚙️ CodeRabbit configuration file

Files:

  • ghost/core/core/server/services/jobs-service/register-job-handlers.ts
  • ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
Prioritise concrete correctness, security, data-integrity, compatibility, and regression risks.

⚙️ CodeRabbit configuration file

Files:

  • ghost/core/core/server/services/jobs-service/register-job-handlers.ts
  • ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts
  • ghost/core/core/boot.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
Type-safe boundaries: Fail only if the PR: consumes boundary data (HTTP input, external API/SDK responses, env/config, DB/filesystem reads, queue/webhook/event payloads) without validating it first — Zod by default, another format only wher...

📄 CodeRabbit inference engine (Custom checks)

Files:

  • ghost/core/core/server/services/jobs-service/register-job-handlers.ts
  • ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
New files are TypeScript: Fail if the PR adds a new .js/.jsx/.cjs/.mjs source file, unless it is: a DB migration (ghost/core/core/server/data/migrations/), under apps/ember-admin/, a tool/config file, under scripts/ or docker/, or generated...

📄 CodeRabbit inference engine (Custom checks)

Files:

  • ghost/core/core/boot.js
🧠 Learnings (1)
📚 Learning: 2026-08-03T21:09:05.797Z
Learnt from: troyciesco
Repo: TryGhost/Ghost PR: 29723
File: ghost/core/test/unit/server/services/automations/automations-repository.test.ts:2117-2117
Timestamp: 2026-08-03T21:09:05.797Z
Learning: In TypeScript test files, treat each `it(...)` or `test(...)` callback as a separate function scope. Identically named local declarations, such as `queries` or `recordQuery`, in separate test callbacks are valid and should not be reported as duplicate block-scoped declarations.

Applied to files:

  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
🔇 Additional comments (5)
ghost/core/core/boot.js (1)

504-513: LGTM!

ghost/core/core/server/services/jobs-service/register-job-handlers.ts (1)

7-7: LGTM!

Also applies to: 60-62

ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts (1)

19-19: LGTM!

Also applies to: 44-44, 70-88

ghost/core/core/server/services/gifts/gift-service.ts (1)

1410-1410: LGTM!

Also applies to: 1438-1451

ghost/core/test/unit/server/services/gifts/gift-service.test.ts (1)

1666-1733: LGTM!


Walkthrough

Gift reminders now run through the class-based jobs service in the main process. Boot schedules a typed recurring job. The jobs service invokes GiftService.processReminders(). The service logs reminder counts and duration and propagates failures. The legacy worker path was removed. Documentation and tests were updated.

Suggested reviewers: allouis

Priority: ⬇️ Low

Change: Refactor

Merge Risk: ⚪ Minimal · up to 101a5

Gift reminder scheduling follows the initialized jobs-service lifecycle, with no actionable current-head risk identified.

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
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.
Type-Safe Boundaries ✅ Passed The production changes do not introduce an unvalidated boundary-data read. The new job carries no fields, and its handler ignores the queue payload before calling the internal `giftService.processRemi…
New Files Are Typescript ✅ Passed The pull request adds four files, and all four use TypeScript extensions: .ts. It adds no new .js, .jsx, .cjs, or .mjs source file. The JavaScript files in the diff are modified or deleted p…
Title check ✅ Passed The title clearly and concisely describes the primary change: moving the send-gift-reminders job to the class-based jobs service.
Description check ✅ Passed The description is directly related to the changeset. It explains the migration, scheduling changes, legacy-path removal, behavior parity, logging, and test coverage.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch migrate-send-gift-reminders

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

@nx-cloud

nx-cloud Bot commented Sep 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 70a3706

Command Status Duration Result
nx run ghost:test:ci:integration ✅ Succeeded 4m 43s View ↗
nx run ghost:test:integration ✅ Succeeded 3m 47s View ↗
nx run ghost:test:ci:e2e ✅ Succeeded 4m 21s View ↗
nx run ghost:test:legacy ✅ Succeeded 3m 22s View ↗
nx run ghost:test:e2e ✅ Succeeded 2m 56s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 26s View ↗
nx run-many -t test:unit -p ghost ✅ Succeeded 38s View ↗
nx run-many -t lint -p ghost-monorepo,ghost ✅ Succeeded 1s View ↗
Additional runs (4) ✅ Succeeded ... View ↗

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


☁️ Nx Cloud last updated this comment at 2026-09-15 16:35:18 UTC

@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 27.77778% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.74%. Comparing base (f0f0de5) to head (101a509).

Files with missing lines Patch % Lines
...host/core/core/server/services/gifts/jobs/index.js 0.00% 8 Missing ⚠️
ghost/core/core/boot.js 0.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #30730      +/-   ##
==========================================
+ Coverage   67.73%   67.74%   +0.01%     
==========================================
  Files        1677     1677              
  Lines       60608    60591      -17     
  Branches    10488    10483       -5     
==========================================
- Hits        41052    41050       -2     
+ Misses      17233    17221      -12     
+ Partials     2323     2320       -3     
Flag Coverage Δ
e2e-tests 70.53% <27.77%> (+0.02%) ⬆️

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.

@vershwal
vershwal requested a review from allouis September 15, 2026 03:57
Comment thread ghost/core/core/server/services/gifts/gift-service.ts Outdated

@allouis allouis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good, but I would consider pulling the logging to where the work happens, rather than adding a wrapper

ref https://linear.app/ghost/issue/HKG-1976

- Review asked for the logging to live where the work happens rather
  than behind a wrapper only the job handler called
- The jobs service lifecycle log carries no counts, so the poll still has
  to report its own sent, not due and rejected summary
- The empty-poll early return went so every run reports a summary, which
  is what the daily job already did
- The exact scheduler runs the same poll, so it now reports the same
  summary instead of the daily job alone
@vershwal
vershwal merged commit 7bbe102 into main Sep 15, 2026
58 checks passed
@vershwal
vershwal deleted the migrate-send-gift-reminders branch September 15, 2026 16:40
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.

2 participants