Skip to content

Comments

Switched to time-based domain warmup#25691

Merged
sam-lord merged 1 commit intomainfrom
time-based-warmup
Dec 11, 2025
Merged

Switched to time-based domain warmup#25691
sam-lord merged 1 commit intomainfrom
time-based-warmup

Conversation

@sam-lord
Copy link
Contributor

@sam-lord sam-lord commented Dec 10, 2025

closes GVA-617
closes GVA-619

This uses the new domain warmup algorithm that makes the warmup static:

  • The number of recipients we're targeting is 200,000
  • The number of days in which the warmup takes place is 6 weeks (42 days)

This simplifies the flow considerably, and we can experiment with the exact thresholds to make sure this is effective for most users of domain warming.


Note

Replaces threshold-based scaling with a time-based exponential warmup from 200 to 200k over 42 days, with Infinity after completion, and updates tests accordingly.

  • Email Service (DomainWarmingService.ts)
    • Replace threshold-based scaling table with time-based exponential warmup using WarmupVolumeOptions (start=200, end=200000, totalDays=42).
    • Add #getDaysSinceFirstEmail() querying first csd_email_count email (findPage with csd_email_count:-null, ordered by created_at ASC).
    • getWarmupLimit now computes limit via start * (end/start)^(day/(totalDays-1)) and returns Infinity once day >= totalDays.
  • Tests
    • Integration and unit tests updated to validate time-based limits, day progression, same-day behavior, gaps, and post-warmup Infinity.
    • Introduce deterministic time control with fake timers and adjust assertions to new query and limits.

Written by Cursor Bugbot for commit 4d1012b. This will update automatically on new commits. Configure here.

@sam-lord sam-lord requested a review from aileen December 10, 2025 21:56
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 10, 2025

Walkthrough

This PR refactors the domain warmup logic in DomainWarmingService from a discrete threshold-based scaling approach to a continuous, time-based exponential formula. The service replaces the hardcoded WarmupScalingTable with a new WarmupVolumeOptions configuration and DefaultWarmupOptions constant. The warmup limit calculation now uses an exponential ramp formula (limit = start * (end/start)^(day/(totalDays-1))) based on days since the first email, with an upper bound that returns Infinity once the warmup period completes. The constructor now accepts laboratories and config services as dependencies. Tests are updated to validate the new exponential progression using time-based scenarios with fake timers.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • The exponential formula implementation and edge case handling (particularly day 0 boundaries and the Infinity return at totalDays)
  • Math precision in test assertions with floating-point exponential calculations across both integration and unit tests
  • Verification that expected values in test cases correctly reflect the new formula: start * (end/start)^(day/(totalDays-1))
  • The integration of time-based tracking via #getDaysSinceFirstEmail() and the query changes to filter and order email records appropriately

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Switched to time-based domain warmup' clearly and concisely describes the main change in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description clearly references the associated Linear tickets (GVA-617 and GVA-619) and explains the new time-based warmup algorithm implementation with target recipients (200,000) and warmup duration (42 days).
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch time-based-warmup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

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 (3)
ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts (1)

16-16: Explicit clock restoration might be redundant.

The fake timer is explicitly restored on line 36, but sinon.restore() on line 37 already handles cleanup of all Sinon modifications including fake timers. While this explicit restoration is not harmful, it may be unnecessary.

Based on learnings, sinon.restore() in afterEach hooks handles cleanup of all sinon modifications including fake timers, so explicit restoration of individual sinon features is typically not needed when there's a top-level sinon.restore() call.

Consider simplifying to:

-    afterEach(function () {
-        clock.restore();
-        sinon.restore();
-    });
+    afterEach(function () {
+        sinon.restore();
+    });

Also applies to: 31-32, 36-36

ghost/core/test/integration/services/email-service/domain-warming.test.js (2)

199-207: Clarify the formula in comments.

The comment on line 200 states 200 * (200000/200)^(1/41) but simplifies the ratio presentation. Consider showing the full calculation for clarity:

-            // Time-based warmup: limit = start * (end/start)^(day/(totalDays-1))
-            // Day 1: 200 * (200000/200)^(1/41) ≈ 237
+            // Time-based warmup: limit = start * (end/start)^(day/(totalDays-1))
+            // Day 1: 200 * (200000/200)^(1/41) = 200 * (1000)^(1/41) ≈ 237

298-303: Consider extracting the helper for reusability.

The getExpectedLimit helper encapsulates the warmup formula calculation well. If this pattern is needed in other tests, consider extracting it to a shared test utility.

For example, at the top of the test file:

// Helper: Calculate expected warmup limit for a given day
function getExpectedLimit(day, totalCount = Infinity) {
    const start = 200;
    const end = 200000;
    const totalDays = 42;
    const limit = Math.round(start * Math.pow(end / start, day / (totalDays - 1)));
    return Math.min(totalCount, limit);
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 287d2a3 and 4d1012b.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/email-service/DomainWarmingService.ts (3 hunks)
  • ghost/core/test/integration/services/email-service/domain-warming.test.js (4 hunks)
  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts (11 hunks)
🧰 Additional context used
🧠 Learnings (6)
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Use factory pattern for all test data creation instead of hard-coded data or direct database manipulation

Applied to files:

  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
📚 Learning: 2025-09-02T13:06:50.918Z
Learnt from: kevinansfield
Repo: TryGhost/Ghost PR: 24779
File: ghost/core/test/unit/server/services/members/members-api/controllers/RouterController.test.js:1388-1392
Timestamp: 2025-09-02T13:06:50.918Z
Learning: In Ghost's test files, sinon.restore() in afterEach hooks handles cleanup of all sinon modifications including fake timers, spies, and stubs, so explicit restoration of individual sinon features like clock.restore() is typically not needed when there's a top-level sinon.restore() call.

Applied to files:

  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Never use hard-coded waits like `waitForTimeout()`

Applied to files:

  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Applies to e2e/**/*.test.ts : Use Playwright's auto-waiting capabilities and run tests multiple times to ensure stability

Applied to files:

  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
📚 Learning: 2025-11-24T17:29:43.865Z
Learnt from: CR
Repo: TryGhost/Ghost PR: 0
File: e2e/AGENTS.md:0-0
Timestamp: 2025-11-24T17:29:43.865Z
Learning: Each test receives fresh Ghost instance for automatic isolation

Applied to files:

  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
📚 Learning: 2025-10-30T17:13:26.190Z
Learnt from: sam-lord
Repo: TryGhost/Ghost PR: 25303
File: ghost/core/core/server/services/email-service/BatchSendingService.js:19-19
Timestamp: 2025-10-30T17:13:26.190Z
Learning: In ghost/core/core/server/services/email-service/BatchSendingService.js and similar files in the Ghost codebase, prefer using `{...options}` spread syntax without explicit guards like `...(options || {})` when spreading potentially undefined objects, as the maintainer prefers cleaner syntax over defensive patterns when the behavior is safe.

Applied to files:

  • ghost/core/test/integration/services/email-service/domain-warming.test.js
  • ghost/core/core/server/services/email-service/DomainWarmingService.ts
🧬 Code graph analysis (1)
ghost/core/test/integration/services/email-service/domain-warming.test.js (2)
ghost/core/test/unit/server/services/email-service/email-service.test.js (1)
  • assert (2-2)
ghost/core/test/unit/server/services/email-service/batch-sending-service.test.js (5)
  • assert (4-4)
  • sendEmail (96-102)
  • sendEmail (125-131)
  • sendEmail (152-164)
  • sendEmail (193-199)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (9)
  • GitHub Check: Legacy tests (Node 22.18.0, mysql8)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Acceptance tests (Node 22.18.0, sqlite3)
  • GitHub Check: Unit tests (Node 22.18.0)
  • GitHub Check: Acceptance tests (Node 22.18.0, mysql8)
  • GitHub Check: Lint
  • GitHub Check: Legacy tests (Node 22.18.0, sqlite3)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Build & Push Docker Image
🔇 Additional comments (9)
ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts (3)

110-115: LGTM! Clean helper for generating test dates.

The daysAgo helper provides a clear, consistent way to generate timestamps for time-based testing. Good alignment with the fixed clock date.


248-282: LGTM! Comprehensive warmup progression validation.

Excellent test coverage of the exponential warmup formula across key inflection points (day 0, 1, 5, 10, 20, 21, 30, 40, 41). The expected values are mathematically correct based on the formula start * (end/start)^(day/(totalDays-1)).


284-320: LGTM! Proper validation of warmup completion behavior.

These tests correctly verify that the warmup limit becomes Infinity after the 42-day period completes, ensuring no artificial limits are imposed on mature sending domains.

ghost/core/test/integration/services/email-service/domain-warming.test.js (2)

223-247: LGTM! Clear multi-day progression validation.

The test correctly validates the exponential warmup formula across multiple days with clear documentation of expected values at each step.


186-207: LGTM! Correct validation of day-to-day progression.

The test properly verifies that the warmup limit increases from day 0 to day 1 according to the exponential formula, and correctly uses setDay(1) to advance time.

ghost/core/core/server/services/email-service/DomainWarmingService.ts (4)

18-28: LGTM! Clean configuration structure.

The WarmupVolumeOptions type and DefaultWarmupOptions constant provide a clear, maintainable way to configure the warmup parameters. The defaults (200 → 200,000 over 42 days) align with the PR objectives.


75-75: LGTM! Correct day calculation with Date.now().

Using Date.now() instead of new Date() ensures compatibility with Sinon's fake timers during testing. The Math.floor correctly rounds down partial days.


83-98: LGTM! Mathematically sound exponential warmup formula.

The implementation correctly applies the exponential formula limit = start * (end/start)^(day/(totalDays-1)):

  • Day 0 produces the start value (200)
  • Day 41 produces the end value (200,000)
  • Day ≥ 42 returns Infinity (no limit)
  • Result is capped at emailCount to prevent oversending

The use of totalDays - 1 in the denominator is correct for achieving the exact end value on the final day.


64-68: LGTM! Proper query filtering for warmup progression.

The query correctly filters for emails with csd_email_count:-null (emails sent via custom domain) and orders by created_at ASC to find the first email in the warmup sequence.

Comment on lines +64 to 76
async #getDaysSinceFirstEmail(): Promise<number> {
const res = await this.#emailModel.findPage({
filter: 'csd_email_count:-null',
order: 'created_at ASC',
limit: 1
});

if (!result.data.length) {
if (!res.data.length) {
return 0;
}

const count = result.data[0].get('csd_email_count');
return count || 0;
return Math.floor((Date.now() - new Date(res.data[0].get('created_at') as string).getTime()) / (1000 * 60 * 60 * 24));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find Email model definition and check created_at field type
find . -type f \( -name "*.ts" -o -name "*.js" \) | xargs rg -l "class Email|interface Email|export.*Email" | head -20

Repository: TryGhost/Ghost

Length of output: 1210


🏁 Script executed:

# Search for Email model in common locations
rg -nP --type=ts -C5 "class Email" --max-count=5

Repository: TryGhost/Ghost

Length of output: 4088


🏁 Script executed:

# Look for Email model definition or interface
rg -nP --type=ts "created_at" -B2 -A2 | grep -A5 -B5 "Email\|model" | head -60

Repository: TryGhost/Ghost

Length of output: 4957


🏁 Script executed:

# Search for Email model or EmailRepository with Bookshelf
rg -nP --type=ts -C3 "class.*Email.*extends|EmailRepository|Email.*model" | head -80

Repository: TryGhost/Ghost

Length of output: 1202


🏁 Script executed:

# Look for Email model in core/server directory specifically
find ./ghost/core/core/server -type f -name "*mail*" -o -name "*email*" | grep -i "model\|repository" | head -20

Repository: TryGhost/Ghost

Length of output: 429


🏁 Script executed:

# Search for where this.#emailModel is defined in DomainWarmingService
rg -nP --type=ts "emailModel\|Email.*Model" ./ghost/core/core/server/services/email-service/ -B3 -A3

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Read the Email model definition
cat -n ./ghost/core/core/server/models/email.js | head -100

Repository: TryGhost/Ghost

Length of output: 3328


🏁 Script executed:

# Read the DomainWarmingService file to understand the context
cat -n ./ghost/core/core/server/services/email-service/DomainWarmingService.ts | head -100

Repository: TryGhost/Ghost

Length of output: 3600


🏁 Script executed:

# Check what Bookshelf's get method returns for timestamps
rg -nP --type=ts "get\(" ./ghost/core/core/server/services -A1 -B1 | grep -A2 -B2 "created_at\|Date\|timestamp" | head -40

Repository: TryGhost/Ghost

Length of output: 2670


🏁 Script executed:

# Search for how other services handle created_at from Bookshelf models
rg -nP --type=ts "\.get\('created_at'\)" -B2 -A2 | head -60

Repository: TryGhost/Ghost

Length of output: 2822


Change type assertion from as string to as Date to match codebase conventions.

The created_at field from Bookshelf models is consistently cast as Date across similar repositories in the codebase (e.g., BookshelfClickEventRepository, BookshelfRecommendationRepository, BookshelfSubscribeEventRepository). Line 75 should use as Date instead of as string for consistency and correctness.

🤖 Prompt for AI Agents
In ghost/core/core/server/services/email-service/DomainWarmingService.ts around
lines 64 to 76, the created_at value is being asserted as a string then passed
to new Date; update the assertion to match codebase conventions by casting the
Bookshelf field to Date (as Date) and use its getTime() directly (e.g.
(res.data[0].get('created_at') as Date).getTime()) so the type reflects the
model and avoids reconstructing a Date from a string.

Copy link
Member

@aileen aileen left a comment

Choose a reason for hiding this comment

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

So much simpler! Really nice. The tests helped me a lot as well, to understand the handling of the very first email to be send. 👍

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