Skip to content

Commit c57b4e4

Browse files
committed
Handled two processes booting a site with no members-migrations row
ref https://linear.app/ghost/issue/HKG-1983/move-members-migrations-out-of-the-background-jobs-system The row is written after the backfills run, so two processes booting a site that has no row yet both reach the insert, and the unique index on jobs.name rejects the second one. The old job claimed the row before running, so the loser used to wait instead of failing. The runner now treats a failed insert as done when the row exists afterwards, and still rethrows when it does not. Both processes running the backfills in that window is accepted: every site that has booted since Ghost 5.6 already has the row, and the backfills do nothing without Stripe, which a site on its first boot never has.
1 parent 799bc48 commit c57b4e4

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

ghost/core/core/server/services/members/service.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ const membersMigrationJobName = 'members-migrations';
126126
* row is written after the run, so a boot that dies mid-way retries next time.
127127
* The row is written even when Stripe is not configured, matching the job-based
128128
* version: a site that connects Stripe later never runs these 2021-era backfills.
129+
* Two processes booting a site with no row will both run the backfills, where the
130+
* job-based version claimed the row first. Accepted: every site that has booted
131+
* since Ghost 5.6 has the row, and the backfills do nothing without Stripe.
129132
*
130133
* @TODO: Delete the backfills, this runner and the `jobs` rows in the next major
131134
*
@@ -157,8 +160,18 @@ async function runStripeMigrations(stripeService) {
157160
const attrs = { status, started_at: new Date(startedAt), finished_at: new Date() };
158161
if (existingJob) {
159162
await models.Job.edit(attrs, { id: existingJob.id });
160-
} else {
163+
return;
164+
}
165+
166+
try {
161167
await models.Job.add({ name: membersMigrationJobName, ...attrs });
168+
} catch (err) {
169+
// Two processes booting a site with no row yet both get here, and the unique
170+
// index on jobs.name rejects the second insert. The row exists, so move on.
171+
if (!(await models.Job.findOne({ name: membersMigrationJobName }))) {
172+
throw err;
173+
}
174+
logging.warn(`Stripe ${membersMigrationJobName} row was already written by another process`);
162175
}
163176
}
164177

ghost/core/test/e2e-server/services/members-migrations.test.js

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict');
22
const sinon = require('sinon');
33
const logging = require('@tryghost/logging');
44
const { agentProvider } = require('../../utils/e2e-framework');
5+
const db = require('../../../core/server/data/db');
56
const models = require('../../../core/server/models');
67
const jobsService = require('../../../core/server/services/jobs');
78
const membersService = require('../../../core/server/services/members');
@@ -32,12 +33,11 @@ describe('Members migrations on boot', function () {
3233
let jobId;
3334

3435
beforeEach(async function () {
36+
const attrs = { status: 'finished', started_at: STALE, finished_at: STALE };
3537
const job = await models.Job.findOne({ name: JOB_NAME });
36-
jobId = job.id;
37-
await models.Job.edit(
38-
{ status: 'finished', started_at: STALE, finished_at: STALE },
39-
{ id: jobId },
40-
);
38+
jobId = job
39+
? (await models.Job.edit(attrs, { id: job.id })).id
40+
: (await models.Job.add({ name: JOB_NAME, ...attrs })).id;
4141
});
4242

4343
it('skips the migrations and writes nothing when the row already exists', async function () {
@@ -91,5 +91,41 @@ describe('Members migrations on boot', function () {
9191
assert.equal(after.get('status'), 'failed');
9292
assert.ok(after.get('started_at') > STALE, 'expected the failed run to rewrite the row');
9393
});
94+
95+
it('keeps booting when another process writes the row first', async function () {
96+
await db.knex('jobs').where({ name: JOB_NAME }).del();
97+
sinon.stub(stripeService.migrations, 'execute').resolves();
98+
const add = sinon.stub(models.Job, 'add').callsFake(async function (...args) {
99+
// The other process inserts the row first, then our own insert hits the
100+
// unique index on jobs.name. The runner never inspects the error itself:
101+
// any failed insert with a row present takes the warn path.
102+
await add.wrappedMethod.apply(this, args);
103+
return add.wrappedMethod.apply(this, args);
104+
});
105+
const loggingWarn = sinon.stub(logging, 'warn');
106+
107+
await membersService.init();
108+
109+
sinon.assert.calledOnce(add);
110+
sinon.assert.calledWithMatch(loggingWarn, /row was already written by another process/);
111+
112+
const rows = await db.knex('jobs').where({ name: JOB_NAME });
113+
assert.equal(rows.length, 1);
114+
assert.equal(rows[0].status, 'finished');
115+
});
116+
117+
it('fails boot when the insert fails and no other process wrote the row', async function () {
118+
await db.knex('jobs').where({ name: JOB_NAME }).del();
119+
const execute = sinon.stub(stripeService.migrations, 'execute').resolves();
120+
sinon.stub(models.Job, 'add').rejects(new Error('insert exploded'));
121+
const loggingWarn = sinon.stub(logging, 'warn');
122+
123+
await assert.rejects(membersService.init(), { message: 'insert exploded' });
124+
125+
sinon.assert.calledOnce(execute);
126+
sinon.assert.notCalled(loggingWarn);
127+
const rows = await db.knex('jobs').where({ name: JOB_NAME });
128+
assert.equal(rows.length, 0);
129+
});
94130
});
95131
});

0 commit comments

Comments
 (0)