Skip to content

Conversation

@tomerqodo
Copy link

@tomerqodo tomerqodo commented Jan 21, 2026

Benchmark PR from qodo-benchmark#242

Summary by CodeRabbit

  • Bug Fixes

    • Improved email domain warming scaling logic with better high-volume email handling and refined threshold calculations for more consistent progression.
    • Enhanced email count querying for more efficient data retrieval.
  • Tests

    • Updated integration and unit tests to reflect revised scaling behavior and ensure deterministic email warming progression.

✏️ Tip: You can customize this high-level summary in your review settings.

sam-lord and others added 2 commits January 21, 2026 15:51
closes
[GVA-605](https://linear.app/ghost/issue/GVA-605/adjust-the-warmup-numbers-to-reflect-joeys-suggestions)

Our initial scaling factors were based on some preliminary research, and
were designed to be as simple as possible. After further discussions,
we've decided to avoid having such a fast ramp-up at the early stage,
and limit the growth at the later stages to 75k+ per send. That should
still prevent any large sites from going back into warm-up between
sends, but is more likely to avoid damaging the reputation by scaling
too aggressively.
@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

Walkthrough

The changes update the email domain warming service by replacing single-record database queries with paginated results, reorganizing warmup scaling thresholds with new intermediate milestones, and introducing high-volume scaling constraints that limit growth above 400,000 using both relative and absolute increase caps.

Changes

Cohort / File(s) Summary
Service Logic Updates
ghost/core/core/server/services/email-service/DomainWarmingService.ts
Replaced EmailModel.findOne with findPage accepting limit parameter; restructured WarmupScalingTable removing defaultScale and adding highVolume config with threshold, maxScale, and maxAbsoluteIncrease. Added intermediate thresholds at 1,000 and 5,000; adjusted scales at 100,000 (2→1.75) and 400,000 (1.5→2). Updated getHighestCount to read from result.data[0]; modified getTargetLimit to include high-volume branch applying ceiling of scaled and absolute increases, returning minimum. Changed threshold comparison from <= to <.
Integration Test Updates
ghost/core/test/integration/services/email-service/domain-warming.test.js
Introduced fixed baseDate for deterministic test progression. Replaced dynamic expected-limit calculations with fixed 1.25× scaling factor for Day 2 and Day 3 assertions. Updated test expectations to reflect consistent daily scaling (Day 1: 200, Day 2: ~250, Day 3: ~313). Added getExpectedScale(count) helper function to centralize threshold-based scaling determination.
Unit Test Updates
ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
Migrated model mocks from findOne to findAll/findPage with updated type declarations. Updated getWarmupLimit test stubs to return {data: []} format with limit parameter. Expanded test coverage for scaling progression with new conservative sequence. Adjusted Email model construction to use findAll: [{csd_email_count: ...}] pattern across all test scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 ✉️
Warmup thresholds triple-fold,
New volumes scaling, growing bold,
From findOne's grasp to pages wide,
Domain heat with measured stride,
Scaling caps and ceilings bright—
Email warming done just right!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: updates to scaling factors for domain warmup logic, which is reflected across multiple test files and the main service implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

Copy link

@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: 2

🤖 Fix all issues with AI agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts`:
- Around line 100-113: The method `#getHighestCount` currently builds a filter for
`#emailModel.findPage` using "<=YYYY-MM-DD", which includes today's emails; change
the filter operator to "<" so it excludes today as intended (keep the same date
expression new Date().toISOString().split('T')[0] and the rest of the findPage
options and return logic intact).
- Around line 131-135: The loop in DomainWarmingService that iterates
WARMUP_SCALING_TABLE.thresholds uses a strict `<` comparison which misclassifies
boundary values (e.g., lastCount === threshold.limit) and also calls sort() on
the thresholds on every invocation; change the comparison from `<` to `<=` so
boundary values map to the intended tier (affecting the branch that returns
Math.ceil(lastCount * threshold.scale)), and avoid mutating the original array
each call by pre-sorting thresholds once (e.g., sort
WARMUP_SCALING_TABLE.thresholds at initialization or use a non-mutating
toSorted() result) so the function uses an already-sorted thresholds list
instead of sorting in-place every time.
🧹 Nitpick comments (3)
ghost/core/core/server/services/email-service/DomainWarmingService.ts (1)

124-129: Consider renaming variables for clarity.

The variable names scaledIncrease and absoluteIncrease are misleading—both represent the target limit, not the increase amount. Consider scaledLimit and absoluteCapLimit for clarity.

Suggested naming improvement
         // For high volume senders (400k+), cap the increase at 20% or 75k absolute
         if (lastCount >= WARMUP_SCALING_TABLE.highVolume.threshold) {
-            const scaledIncrease = Math.ceil(lastCount * WARMUP_SCALING_TABLE.highVolume.maxScale);
-            const absoluteIncrease = lastCount + WARMUP_SCALING_TABLE.highVolume.maxAbsoluteIncrease;
-            return Math.min(scaledIncrease, absoluteIncrease);
+            const scaledLimit = Math.ceil(lastCount * WARMUP_SCALING_TABLE.highVolume.maxScale);
+            const absoluteCapLimit = lastCount + WARMUP_SCALING_TABLE.highVolume.maxAbsoluteIncrease;
+            return Math.min(scaledLimit, absoluteCapLimit);
         }
ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts (1)

178-184: Test assertion may not catch filter operator mismatch.

The includes() check on line 181 would pass for both created_at:< and created_at:<= since the latter contains the former as a substring. Consider using a stricter assertion to verify the exact filter operator:

Suggested fix
             sinon.assert.calledOnce(findPageStub);
             const callArgs = findPageStub.firstCall.args[0];
             assert.ok(callArgs.filter);
-            assert.ok(callArgs.filter.includes(`created_at:<${today}`));
+            assert.equal(callArgs.filter, `created_at:<${today}`);
             assert.equal(callArgs.order, 'csd_email_count DESC');
             assert.equal(callArgs.limit, 1);
ghost/core/test/integration/services/email-service/domain-warming.test.js (1)

290-301: Helper is incomplete for higher volume tiers.

The getExpectedScale helper only covers thresholds up to 1.75× and is missing the 2× tier (100k–400k) and high-volume cap (400k+). While this doesn't affect the current test (max ~800 recipients), consider completing it for consistency and future test expansion:

Suggested improvement
             const getExpectedScale = (count) => {
                 if (count <= 100) {
                     return 200;
                 }
                 if (count <= 1000) {
                     return Math.ceil(count * 1.25);
                 }
                 if (count <= 5000) {
                     return Math.ceil(count * 1.5);
                 }
+                if (count <= 100000) {
+                    return Math.ceil(count * 1.75);
+                }
+                if (count <= 400000) {
+                    return Math.ceil(count * 2);
+                }
+                // High volume: min(1.2×, +75k)
+                return Math.min(Math.ceil(count * 1.2), count + 75000);
-                return Math.ceil(count * 1.75);
             };
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5004edc and 48a2166.

📒 Files selected for processing (3)
  • ghost/core/core/server/services/email-service/DomainWarmingService.ts
  • ghost/core/test/integration/services/email-service/domain-warming.test.js
  • ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts
🔇 Additional comments (6)
ghost/core/core/server/services/email-service/DomainWarmingService.ts (2)

5-7: LGTM!

The EmailModel type update to use findPage with paginated response structure is appropriate for fetching the highest count record.


30-65: Good documentation and configuration structure.

The scaling table documentation clearly explains the volume-based multipliers, and the highVolume cap logic prevents aggressive scaling at high volumes.

ghost/core/test/unit/server/services/email-service/domain-warming-service.test.ts (1)

186-227: Test expectations are correct; implementation boundary logic needs fixing.

The test cases correctly expect boundary-inclusive behavior (e.g., lastCount=1000 should use 1.25× scale). These tests will fail against the current implementation which uses < instead of <=. Once the implementation is fixed per the earlier comment, these tests should pass.

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

53-67: Good use of fixed base time for deterministic test behavior.

Using a fixed baseDate with hours set to noon avoids timezone-related flakiness in day boundary calculations.


217-241: LGTM!

The deterministic day-by-day progression with explicit expected values (200 → 250 → 313) makes the test clear and easy to debug.


192-204: LGTM!

The test correctly validates the 1.25× scaling factor for day 2 progression and handles both cases where recipients exceed or fall below the calculated limit.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment on lines 100 to 113
async #getHighestCount(): Promise<number> {
const email = await this.#emailModel.findOne({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC'
const result = await this.#emailModel.findPage({
filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});

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

const count = email.get('csd_email_count');
return count || 0;
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Filter may include today's emails contrary to the documented intent.

The comment on line 98 states "excluding today", but the filter on line 102 uses <= which would include emails created today. If the intent is to exclude today's sends when calculating the next limit, the filter should use < instead.

Proposed fix
     async `#getHighestCount`(): Promise<number> {
         const result = await this.#emailModel.findPage({
-            filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
+            filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
             order: 'csd_email_count DESC',
             limit: 1
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async #getHighestCount(): Promise<number> {
const email = await this.#emailModel.findOne({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC'
const result = await this.#emailModel.findPage({
filter: `created_at:<=${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});
if (!email) {
if (!result.data.length) {
return 0;
}
const count = email.get('csd_email_count');
return count || 0;
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
async `#getHighestCount`(): Promise<number> {
const result = await this.#emailModel.findPage({
filter: `created_at:<${new Date().toISOString().split('T')[0]}`,
order: 'csd_email_count DESC',
limit: 1
});
if (!result.data.length) {
return 0;
}
const count = result.data[0].get('csd_email_count');
return count != null ? count : 0;
}
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 100 - 113, The method `#getHighestCount` currently builds a filter for
`#emailModel.findPage` using "<=YYYY-MM-DD", which includes today's emails; change
the filter operator to "<" so it excludes today as intended (keep the same date
expression new Date().toISOString().split('T')[0] and the rest of the findPage
options and return logic intact).

Comment on lines 131 to 135
for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
if (lastCount <= threshold.limit) {
if (lastCount < threshold.limit) {
return Math.ceil(lastCount * threshold.scale);
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Boundary condition bug: threshold comparison should use <= not <.

The < comparison causes boundary values to fall into the wrong tier. For example, with lastCount = 1000:

  • Current: 1000 < 1000 is false → falls through to 1.5× tier → returns 1500
  • Expected per docs and tests: 1000 should use 1.25× → returns 1250

The same issue affects all boundary values (1000, 5000, 100000, 400000).

Additionally, sort() mutates the array in-place on every call. Consider sorting once at initialization or using toSorted().

Proposed fix
-        for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
-            if (lastCount < threshold.limit) {
+        for (const threshold of [...WARMUP_SCALING_TABLE.thresholds].sort((a, b) => a.limit - b.limit)) {
+            if (lastCount <= threshold.limit) {
                 return Math.ceil(lastCount * threshold.scale);
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const threshold of WARMUP_SCALING_TABLE.thresholds.sort((a, b) => a.limit - b.limit)) {
if (lastCount <= threshold.limit) {
if (lastCount < threshold.limit) {
return Math.ceil(lastCount * threshold.scale);
}
}
for (const threshold of [...WARMUP_SCALING_TABLE.thresholds].sort((a, b) => a.limit - b.limit)) {
if (lastCount <= threshold.limit) {
return Math.ceil(lastCount * threshold.scale);
}
}
🤖 Prompt for AI Agents
In `@ghost/core/core/server/services/email-service/DomainWarmingService.ts` around
lines 131 - 135, The loop in DomainWarmingService that iterates
WARMUP_SCALING_TABLE.thresholds uses a strict `<` comparison which misclassifies
boundary values (e.g., lastCount === threshold.limit) and also calls sort() on
the thresholds on every invocation; change the comparison from `<` to `<=` so
boundary values map to the intended tier (affecting the branch that returns
Math.ceil(lastCount * threshold.scale)), and avoid mutating the original array
each call by pre-sorting thresholds once (e.g., sort
WARMUP_SCALING_TABLE.thresholds at initialization or use a non-mutating
toSorted() result) so the function uses an already-sorted thresholds list
instead of sorting in-place every time.

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.

3 participants