Added short lived caching to email batch body (#16233)

fixes https://github.com/TryGhost/Team/issues/2522

When sending an email for multiple batches at the same time, we now
reuse the same email body for each batch in the same segment. This
reduces the amount of database queries and makes the sending more
reliable in case of database failures.

The cache is short lived. After sending the email it is automatically
garbage collected.
This commit is contained in:
Simon Backx 2023-02-07 11:01:49 +01:00 committed by GitHub
parent aa6242eb6d
commit fb3cbe5fc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 172 additions and 10 deletions

View File

@ -2,6 +2,7 @@ const logging = require('@tryghost/logging');
const ObjectID = require('bson-objectid').default;
const errors = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const EmailBodyCache = require('./email-body-cache');
const messages = {
emailErrorPartialFailure: 'An error occurred, and your newsletter was only partially sent. Please retry sending the remaining emails.',
@ -317,6 +318,9 @@ class BatchSendingService {
async sendBatches({email, batches, post, newsletter}) {
logging.info(`Sending ${batches.length} batches for email ${email.id}`);
// Reuse same HTML body if we send an email to the same segment
const emailBodyCache = new EmailBodyCache();
// Loop batches and send them via the EmailProvider
let succeededCount = 0;
const queue = batches.slice();
@ -326,7 +330,7 @@ class BatchSendingService {
runNext = async () => {
const batch = queue.shift();
if (batch) {
if (await this.sendBatch({email, batch, post, newsletter})) {
if (await this.sendBatch({email, batch, post, newsletter, emailBodyCache})) {
succeededCount += 1;
}
await runNext();
@ -353,7 +357,7 @@ class BatchSendingService {
* @param {{email: Email, batch: EmailBatch, post: Post, newsletter: Newsletter}} data
* @returns {Promise<boolean>} True when succeeded, false when failed with an error
*/
async sendBatch({email, batch: originalBatch, post, newsletter}) {
async sendBatch({email, batch: originalBatch, post, newsletter, emailBodyCache}) {
logging.info(`Sending batch ${originalBatch.id} for email ${email.id}`);
// Check the status of the email batch in a 'for update' transaction
@ -397,7 +401,8 @@ class BatchSendingService {
members
}, {
openTrackingEnabled: !!email.get('track_opens'),
clickTrackingEnabled: !!email.get('track_clicks')
clickTrackingEnabled: !!email.get('track_clicks'),
emailBodyCache
});
succeeded = true;

View File

@ -0,0 +1,20 @@
/**
* This is a cache provider that lives very short in memory, there is no need for persistence.
* It is created when scheduling an email in the batch sending service, and is then passed to the sending service. The sending service
* can optionally use a passed cache provider to reuse the email body for each batch with the same segment.
*/
class EmailBodyCache {
constructor() {
this.cache = new Map();
}
get(key) {
return this.cache.get(key) ?? null;
}
set(key, value) {
this.cache.set(key, value);
}
}
module.exports = EmailBodyCache;

View File

@ -22,12 +22,14 @@ const logging = require('@tryghost/logging');
/**
* @typedef {import("./email-renderer")} EmailRenderer
* @typedef {import("./email-renderer").EmailBody} EmailBody
*/
/**
* @typedef {object} EmailSendingOptions
* @prop {boolean} clickTrackingEnabled
* @prop {boolean} openTrackingEnabled
* @prop {{get(id: string): EmailBody | null, set(id: string, body: EmailBody): void}} [emailBodyCache]
*/
/**
@ -85,12 +87,30 @@ class SendingService {
* @returns {Promise<EmailProviderSuccessResponse>}
*/
async send({post, newsletter, segment, members, emailId}, options) {
const emailBody = await this.#emailRenderer.renderBody(
post,
newsletter,
segment,
options
);
const cacheId = emailId + '-' + (segment ?? 'null');
/**
* @type {EmailBody | null}
*/
let emailBody = null;
if (options.emailBodyCache) {
emailBody = options.emailBodyCache.get(cacheId);
}
if (!emailBody) {
emailBody = await this.#emailRenderer.renderBody(
post,
newsletter,
segment,
{
clickTrackingEnabled: !!options.clickTrackingEnabled
}
);
if (options.emailBodyCache) {
options.emailBodyCache.set(cacheId, emailBody);
}
}
const recipients = this.buildRecipients(members, emailBody.replacements);
return await this.#emailProvider.send({
@ -102,7 +122,10 @@ class SendingService {
recipients,
emailId: emailId,
replacementDefinitions: emailBody.replacements
}, options);
}, {
clickTrackingEnabled: !!options.clickTrackingEnabled,
openTrackingEnabled: !!options.openTrackingEnabled
});
}
/**

View File

@ -1,6 +1,7 @@
const SendingService = require('../lib/sending-service');
const sinon = require('sinon');
const assert = require('assert');
const EmailBodyCache = require('../lib/email-body-cache');
describe('Sending service', function () {
describe('send', function () {
@ -102,6 +103,119 @@ describe('Sending service', function () {
));
});
it('supports cache', async function () {
const emailBodyCache = new EmailBodyCache();
const sendingService = new SendingService({
emailRenderer,
emailProvider
});
const response = await sendingService.send({
post: {},
newsletter: {},
segment: null,
emailId: '123',
members: [
{
email: 'member@example.com',
name: 'John'
}
]
}, {
clickTrackingEnabled: true,
openTrackingEnabled: true,
emailBodyCache
});
assert.equal(response.id, 'provider-123');
sinon.assert.calledOnce(sendStub);
sinon.assert.calledOnce(emailRenderer.renderBody);
assert(sendStub.calledWith(
{
subject: 'Hi',
from: 'ghost@example.com',
replyTo: 'ghost+reply@example.com',
html: '<html><body>Hi {{name}}</body></html>',
plaintext: 'Hi',
emailId: '123',
replacementDefinitions: [
{
id: 'name',
token: '{{name}}',
getValue: sinon.match.func
}
],
recipients: [
{
email: 'member@example.com',
replacements: [{
id: 'name',
token: '{{name}}',
value: 'John'
}]
}
]
},
{
clickTrackingEnabled: true,
openTrackingEnabled: true
}
));
// Do again and see if cache is used
const response2 = await sendingService.send({
post: {},
newsletter: {},
segment: null,
emailId: '123',
members: [
{
email: 'member@example.com',
name: 'John'
}
]
}, {
clickTrackingEnabled: true,
openTrackingEnabled: true,
emailBodyCache
});
assert.equal(response2.id, 'provider-123');
sinon.assert.calledTwice(sendStub);
assert(sendStub.getCall(1).calledWith(
{
subject: 'Hi',
from: 'ghost@example.com',
replyTo: 'ghost+reply@example.com',
html: '<html><body>Hi {{name}}</body></html>',
plaintext: 'Hi',
emailId: '123',
replacementDefinitions: [
{
id: 'name',
token: '{{name}}',
getValue: sinon.match.func
}
],
recipients: [
{
email: 'member@example.com',
replacements: [{
id: 'name',
token: '{{name}}',
value: 'John'
}]
}
]
},
{
clickTrackingEnabled: true,
openTrackingEnabled: true
}
));
// Didn't call renderBody again
sinon.assert.calledOnce(emailRenderer.renderBody);
});
it('removes invalid recipients before sending', async function () {
const sendingService = new SendingService({
emailRenderer,