Skip to content

Commit 4ebd10a

Browse files
authored
Added transaction support to limit-service (#190)
refs TryGhost/Ghost#14780 refs https://github.com/TryGhost/Team/issues/1583 - We need transaction support in the limit-service so that we can run the count queries in the same transaction - This is required to avoid deadlocks when we check the limits when a transaction is in progress on the same tables - This issue specifically is required for newsletters, where we start a transaction when creating a newsletter. - Bumped `eslint-plugin-ghost` so we have newer ECMA features available - Updated README - Renamed `metadata` to `options` in `limit-service`
1 parent d95352c commit 4ebd10a

File tree

6 files changed

+422
-46
lines changed

6 files changed

+422
-46
lines changed

packages/limit-service/README.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ or
1717
Below is a sample code to wire up limit service and perform few common limit checks:
1818

1919
```js
20+
const knex = require('knex');
2021
const errors = require('@tryghost/errors');
2122
const LimitService = require('@tryghost/limit-service');
2223

@@ -80,15 +81,17 @@ const subscription = {
8081
const helpLink = 'https://ghost.org/help/';
8182

8283
// initialize knex db connection for the limit service to use when running query checks
83-
const db = knex({
84-
client: 'mysql',
85-
connection: {
86-
user: 'root',
87-
password: 'toor',
88-
host: 'localhost',
89-
database: 'ghost',
90-
}
91-
});
84+
const db = {
85+
knex: knex({
86+
client: 'mysql',
87+
connection: {
88+
user: 'root',
89+
password: 'toor',
90+
host: 'localhost',
91+
database: 'ghost',
92+
}
93+
});
94+
};
9295

9396
// finish initializing the limits service
9497
limitService.loadLimits({limits, subscription, db, helpLink, errors});
@@ -133,6 +136,22 @@ if (limitService.checkIfAnyOverLimit()) {
133136
}
134137
```
135138

139+
### Transactions
140+
141+
Some limit types (`max` or `maxPeriodic`) need to fetch the current count from the database. Sometimes you need those checks to also run in a transaction. To fix that, you can pass the `transacting` option to all the available checks.
142+
143+
```js
144+
db.transaction((transacting) => {
145+
const options = {transacting};
146+
147+
await limitService.errorIfWouldGoOverLimit('newsletters', options);
148+
await limitService.errorIfIsOverLimit('newsletters', options);
149+
const a = await limitService.checkIsOverLimit('newsletters', options);
150+
const b = await limitService.checkWouldGoOverLimit('newsletters', options);
151+
const c = await limitService.checkIfAnyOverLimit(options);
152+
});
153+
```
154+
136155
### Types of limits
137156
At the moment there are four different types of limits that limit service allows to define. These types are:
138157
1. `flag` - is an "on/off" switch for certain feature. Example usecase: "disable all emails". It's identified by a `disabled: true` property in the "limits" configuration.

packages/limit-service/lib/config.js

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
// 2. MaxLimit should contain a `currentCountQuery` function which would count the resources under limit
55
module.exports = {
66
members: {
7-
currentCountQuery: async (db) => {
8-
let result = await db.knex('members').count('id', {as: 'count'}).first();
7+
currentCountQuery: async (knex) => {
8+
let result = await knex('members').count('id', {as: 'count'}).first();
99
return result.count;
1010
}
1111
},
1212
newsletters: {
13-
currentCountQuery: async (db) => {
14-
let result = await db.knex('newsletters')
13+
currentCountQuery: async (knex) => {
14+
let result = await knex('newsletters')
1515
.count('id', {as: 'count'})
1616
.where('status', '=', 'active')
1717
.first();
@@ -20,8 +20,8 @@ module.exports = {
2020
}
2121
},
2222
emails: {
23-
currentCountQuery: async (db, startDate) => {
24-
let result = await db.knex('emails')
23+
currentCountQuery: async (knex, startDate) => {
24+
let result = await knex('emails')
2525
.sum('email_count', {as: 'count'})
2626
.where('created_at', '>=', startDate)
2727
.first();
@@ -30,13 +30,13 @@ module.exports = {
3030
}
3131
},
3232
staff: {
33-
currentCountQuery: async (db) => {
34-
let result = await db.knex('users')
33+
currentCountQuery: async (knex) => {
34+
let result = await knex('users')
3535
.select('users.id')
3636
.leftJoin('roles_users', 'users.id', 'roles_users.user_id')
3737
.leftJoin('roles', 'roles_users.role_id', 'roles.id')
3838
.whereNot('roles.name', 'Contributor').andWhereNot('users.status', 'inactive').union([
39-
db.knex('invites')
39+
knex('invites')
4040
.select('invites.id')
4141
.leftJoin('roles', 'invites.role_id', 'roles.id')
4242
.whereNot('roles.name', 'Contributor')
@@ -46,8 +46,8 @@ module.exports = {
4646
}
4747
},
4848
customIntegrations: {
49-
currentCountQuery: async (db) => {
50-
let result = await db.knex('integrations')
49+
currentCountQuery: async (knex) => {
50+
let result = await knex('integrations')
5151
.count('id', {as: 'count'})
5252
.whereNotIn('type', ['internal', 'builtin'])
5353
.first();

packages/limit-service/lib/limit-service.js

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,20 @@ class LimitService {
6767
return !!this.limits[_.camelCase(limitName)];
6868
}
6969

70-
async checkIsOverLimit(limitName) {
70+
/**
71+
*
72+
* @param {String} limitName - name of the configured limit
73+
* @param {Object} [options] - limit parameters
74+
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
75+
* @returns
76+
*/
77+
async checkIsOverLimit(limitName, options = {}) {
7178
if (!this.isLimited(limitName)) {
7279
return;
7380
}
7481

7582
try {
76-
await this.limits[limitName].errorIfIsOverLimit();
83+
await this.limits[limitName].errorIfIsOverLimit(options);
7784
return false;
7885
} catch (error) {
7986
if (error instanceof this.errors.HostLimitError) {
@@ -84,13 +91,20 @@ class LimitService {
8491
}
8592
}
8693

87-
async checkWouldGoOverLimit(limitName, metadata = {}) {
94+
/**
95+
*
96+
* @param {String} limitName - name of the configured limit
97+
* @param {Object} [options] - limit parameters
98+
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
99+
* @returns
100+
*/
101+
async checkWouldGoOverLimit(limitName, options = {}) {
88102
if (!this.isLimited(limitName)) {
89103
return;
90104
}
91105

92106
try {
93-
await this.limits[limitName].errorIfWouldGoOverLimit(metadata);
107+
await this.limits[limitName].errorIfWouldGoOverLimit(options);
94108
return false;
95109
} catch (error) {
96110
if (error instanceof this.errors.HostLimitError) {
@@ -104,39 +118,43 @@ class LimitService {
104118
/**
105119
*
106120
* @param {String} limitName - name of the configured limit
107-
* @param {Object} metadata - limit parameters
121+
* @param {Object} [options] - limit parameters
122+
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
108123
* @returns
109124
*/
110-
async errorIfIsOverLimit(limitName, metadata = {}) {
125+
async errorIfIsOverLimit(limitName, options = {}) {
111126
if (!this.isLimited(limitName)) {
112127
return;
113128
}
114129

115-
await this.limits[limitName].errorIfIsOverLimit(metadata);
130+
await this.limits[limitName].errorIfIsOverLimit(options);
116131
}
117132

118133
/**
119134
*
120135
* @param {String} limitName - name of the configured limit
121-
* @param {Object} metadata - limit parameters
136+
* @param {Object} [options] - limit parameters
137+
* @param {Object} [options.transacting] Transaction to run the count query on (if required for the chosen limit)
122138
* @returns
123139
*/
124-
async errorIfWouldGoOverLimit(limitName, metadata = {}) {
140+
async errorIfWouldGoOverLimit(limitName, options = {}) {
125141
if (!this.isLimited(limitName)) {
126142
return;
127143
}
128144

129-
await this.limits[limitName].errorIfWouldGoOverLimit(metadata);
145+
await this.limits[limitName].errorIfWouldGoOverLimit(options);
130146
}
131147

132148
/**
133149
* Checks if any of the configured limits acceded
134-
*
150+
*
151+
* @param {Object} [options] - limit parameters
152+
* @param {Object} [options.transacting] Transaction to run the count queries on (if required for the chosen limit)
135153
* @returns {Promise<boolean>}
136154
*/
137-
async checkIfAnyOverLimit() {
155+
async checkIfAnyOverLimit(options = {}) {
138156
for (const limit in this.limits) {
139-
if (await this.checkIsOverLimit(limit)) {
157+
if (await this.checkIsOverLimit(limit, options)) {
140158
return true;
141159
}
142160
}

packages/limit-service/lib/limit.js

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,13 @@ class MaxLimit extends Limit {
9898
return new this.errors.HostLimitError(errorObj);
9999
}
100100

101-
async currentCountQuery() {
102-
return await this.currentCountQueryFn(this.db);
101+
/**
102+
* @param {Object} [options]
103+
* @param {Object} [options.transacting] Transaction to run the count query on
104+
* @returns
105+
*/
106+
async currentCountQuery(options = {}) {
107+
return await this.currentCountQueryFn(options.transacting ?? this.db?.knex);
103108
}
104109

105110
/**
@@ -108,9 +113,11 @@ class MaxLimit extends Limit {
108113
* @param {Object} options
109114
* @param {Number} [options.max] - overrides configured default max value to perform checks against
110115
* @param {Number} [options.addedCount] - number of items to add to the currentCount during the check
116+
* @param {Object} [options.transacting] Transaction to run the count query on
111117
*/
112-
async errorIfWouldGoOverLimit({max, addedCount = 1} = {}) {
113-
let currentCount = await this.currentCountQuery();
118+
async errorIfWouldGoOverLimit(options = {}) {
119+
const {max, addedCount = 1} = options;
120+
let currentCount = await this.currentCountQuery(options);
114121

115122
if ((currentCount + addedCount) > (max || this.max)) {
116123
throw this.generateError(currentCount);
@@ -123,11 +130,12 @@ class MaxLimit extends Limit {
123130
* @param {Object} options
124131
* @param {Number} [options.max] - overrides configured default max value to perform checks against
125132
* @param {Number} [options.currentCount] - overrides currentCountQuery to perform checks against
133+
* @param {Object} [options.transacting] Transaction to run the count query on
126134
*/
127-
async errorIfIsOverLimit({max, currentCount} = {}) {
128-
currentCount = currentCount || await this.currentCountQuery();
135+
async errorIfIsOverLimit(options = {}) {
136+
const currentCount = options.currentCount || await this.currentCountQuery(options);
129137

130-
if (currentCount > (max || this.max)) {
138+
if (currentCount > (options.max || this.max)) {
131139
throw this.generateError(currentCount);
132140
}
133141
}
@@ -202,10 +210,15 @@ class MaxPeriodicLimit extends Limit {
202210
return new this.errors.HostLimitError(errorObj);
203211
}
204212

205-
async currentCountQuery() {
213+
/**
214+
* @param {Object} [options]
215+
* @param {Object} [options.transacting] Transaction to run the count query on
216+
* @returns
217+
*/
218+
async currentCountQuery(options = {}) {
206219
const lastPeriodStartDate = lastPeriodStart(this.startDate, this.interval);
207220

208-
return await this.currentCountQueryFn(this.db, lastPeriodStartDate);
221+
return await this.currentCountQueryFn(options.transacting ? options.transacting : (this.db ? this.db.knex : undefined), lastPeriodStartDate);
209222
}
210223

211224
/**
@@ -214,9 +227,11 @@ class MaxPeriodicLimit extends Limit {
214227
* @param {Object} options
215228
* @param {Number} [options.max] - overrides configured default maxPeriodic value to perform checks against
216229
* @param {Number} [options.addedCount] - number of items to add to the currentCount during the check
230+
* @param {Object} [options.transacting] Transaction to run the count query on
217231
*/
218-
async errorIfWouldGoOverLimit({max, addedCount = 1} = {}) {
219-
let currentCount = await this.currentCountQuery();
232+
async errorIfWouldGoOverLimit(options = {}) {
233+
const {max, addedCount = 1} = options;
234+
let currentCount = await this.currentCountQuery(options);
220235

221236
if ((currentCount + addedCount) > (max || this.maxPeriodic)) {
222237
throw this.generateError(currentCount);
@@ -228,9 +243,11 @@ class MaxPeriodicLimit extends Limit {
228243
*
229244
* @param {Object} options
230245
* @param {Number} [options.max] - overrides configured default maxPeriodic value to perform checks against
246+
* @param {Object} [options.transacting] Transaction to run the count query on
231247
*/
232-
async errorIfIsOverLimit({max} = {}) {
233-
let currentCount = await this.currentCountQuery();
248+
async errorIfIsOverLimit(options = {}) {
249+
const {max} = options;
250+
let currentCount = await this.currentCountQuery(options);
234251

235252
if (currentCount > (max || this.maxPeriodic)) {
236253
throw this.generateError(currentCount);

0 commit comments

Comments
 (0)