ti2 1.0.124 → 1.0.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/api.yml +108 -1
- package/auth/authHandler.js +3 -0
- package/controllers/__tests__/app.js +48 -3
- package/controllers/__tests__/user.js +87 -1
- package/controllers/app.js +90 -18
- package/controllers/user.js +35 -7
- package/migrations/20260914180000-create-integration-lifecycles.js +67 -0
- package/models/integrationlifecycle.js +44 -0
- package/package.json +1 -1
- package/services/__tests__/integrationLifecycle.db.js +251 -0
- package/services/__tests__/integrationLifecycle.js +563 -0
- package/services/integrationLifecycle.js +496 -0
- package/worker/__tests__/queue-remove.js +77 -0
- package/worker/queue.js +38 -49
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { Op } = require('sequelize');
|
|
4
|
+
|
|
5
|
+
const sqldb = require('../models');
|
|
6
|
+
const { removeJob } = require('../worker/queue');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROVISIONING_TIMEOUT_MS = 5 * 60e3;
|
|
9
|
+
|
|
10
|
+
const identityWhere = ({ userId, integrationId, hint }) => ({
|
|
11
|
+
userId,
|
|
12
|
+
integrationId,
|
|
13
|
+
hint,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const lifecycleError = (status, message) => {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.status = status;
|
|
19
|
+
return error;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const newRequestId = () => crypto.randomBytes(16).toString('hex');
|
|
23
|
+
|
|
24
|
+
const provisioningTimeoutMs = () => {
|
|
25
|
+
const configured = Number(process.env.INTEGRATION_PROVISIONING_TIMEOUT_MS);
|
|
26
|
+
return Number.isFinite(configured) && configured > 0
|
|
27
|
+
? configured
|
|
28
|
+
: DEFAULT_PROVISIONING_TIMEOUT_MS;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const provisioningIsStale = lifecycle => {
|
|
32
|
+
const lastUpdatedAt = lifecycle.updatedAt || lifecycle.requestedAt;
|
|
33
|
+
const lastUpdatedMs = new Date(lastUpdatedAt).getTime();
|
|
34
|
+
return Number.isFinite(lastUpdatedMs)
|
|
35
|
+
&& Date.now() - lastUpdatedMs >= provisioningTimeoutMs();
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const catalogLifecycleEndpoint = () => {
|
|
39
|
+
const eventsUrl = process.env.ti2_events2url_eventsURL;
|
|
40
|
+
if (!eventsUrl) return null;
|
|
41
|
+
const endpoint = new URL(eventsUrl);
|
|
42
|
+
endpoint.pathname = '/productSync/integration-lifecycle';
|
|
43
|
+
endpoint.search = '';
|
|
44
|
+
endpoint.hash = '';
|
|
45
|
+
return endpoint.toString();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const callCatalogLifecycle = async ({
|
|
49
|
+
action,
|
|
50
|
+
userId,
|
|
51
|
+
integrationId,
|
|
52
|
+
hint,
|
|
53
|
+
generation,
|
|
54
|
+
}) => {
|
|
55
|
+
const endpoint = catalogLifecycleEndpoint();
|
|
56
|
+
if (!endpoint) {
|
|
57
|
+
return {
|
|
58
|
+
status: action === 'activate' ? 'active' : 'deleted',
|
|
59
|
+
generation,
|
|
60
|
+
skipped: true,
|
|
61
|
+
reason: 'catalog_lifecycle_not_configured',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const response = await axios.post(
|
|
66
|
+
endpoint,
|
|
67
|
+
{
|
|
68
|
+
action,
|
|
69
|
+
companyId: userId,
|
|
70
|
+
integrationId,
|
|
71
|
+
hint,
|
|
72
|
+
generation,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
headers: {
|
|
76
|
+
Authorization: process.env.ti2_events2url_authorization,
|
|
77
|
+
},
|
|
78
|
+
timeout: Number(process.env.INTEGRATION_LIFECYCLE_TIMEOUT_MS) || 30e3,
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
return response.data;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const responseData = error.response && error.response.data;
|
|
84
|
+
if (responseData && ['retry', 'superseded'].includes(responseData.status)) {
|
|
85
|
+
return responseData;
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const prepareActivation = async ({
|
|
92
|
+
userId,
|
|
93
|
+
integrationId,
|
|
94
|
+
hint,
|
|
95
|
+
transaction,
|
|
96
|
+
}) => {
|
|
97
|
+
const where = identityWhere({ userId, integrationId, hint });
|
|
98
|
+
let lifecycle = await sqldb.IntegrationLifecycle.findOne({
|
|
99
|
+
where,
|
|
100
|
+
transaction,
|
|
101
|
+
lock: transaction.LOCK.UPDATE,
|
|
102
|
+
});
|
|
103
|
+
if (lifecycle && ['deleting', 'external_cleanup', 'failed'].includes(lifecycle.status)) {
|
|
104
|
+
throw lifecycleError(409, 'Integration removal must finish before this integration can be saved again.');
|
|
105
|
+
}
|
|
106
|
+
if (!lifecycle) {
|
|
107
|
+
lifecycle = await sqldb.IntegrationLifecycle.create({
|
|
108
|
+
...where,
|
|
109
|
+
generation: 1,
|
|
110
|
+
status: 'provisioning',
|
|
111
|
+
requestId: newRequestId(),
|
|
112
|
+
retryCount: 0,
|
|
113
|
+
requestedAt: new Date(),
|
|
114
|
+
artifacts: { requiresCatalogActivation: false },
|
|
115
|
+
}, { transaction });
|
|
116
|
+
} else if (lifecycle.status === 'complete') {
|
|
117
|
+
lifecycle.generation += 1;
|
|
118
|
+
lifecycle.status = 'provisioning';
|
|
119
|
+
lifecycle.requestId = newRequestId();
|
|
120
|
+
lifecycle.retryCount = 0;
|
|
121
|
+
lifecycle.requestedAt = new Date();
|
|
122
|
+
lifecycle.lastError = null;
|
|
123
|
+
lifecycle.artifacts = { requiresCatalogActivation: true };
|
|
124
|
+
await lifecycle.save({ transaction });
|
|
125
|
+
} else {
|
|
126
|
+
lifecycle.status = 'provisioning';
|
|
127
|
+
lifecycle.lastError = null;
|
|
128
|
+
if (!lifecycle.artifacts || typeof lifecycle.artifacts !== 'object') {
|
|
129
|
+
lifecycle.artifacts = { requiresCatalogActivation: false };
|
|
130
|
+
}
|
|
131
|
+
await lifecycle.save({ transaction });
|
|
132
|
+
}
|
|
133
|
+
return lifecycle;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const activateCatalog = async lifecycle => {
|
|
137
|
+
const result = await callCatalogLifecycle({
|
|
138
|
+
action: 'activate',
|
|
139
|
+
userId: lifecycle.userId,
|
|
140
|
+
integrationId: lifecycle.integrationId,
|
|
141
|
+
hint: lifecycle.hint,
|
|
142
|
+
generation: lifecycle.generation,
|
|
143
|
+
});
|
|
144
|
+
if (result && result.status === 'active') return result;
|
|
145
|
+
if (result && result.status === 'superseded') {
|
|
146
|
+
throw lifecycleError(409, 'A newer integration lifecycle already exists.');
|
|
147
|
+
}
|
|
148
|
+
if (result && result.status === 'retry') {
|
|
149
|
+
throw lifecycleError(503, 'Product catalog activation is not ready and can be retried.');
|
|
150
|
+
}
|
|
151
|
+
throw lifecycleError(502, 'Product catalog activation returned an unexpected response.');
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const activationWhere = lifecycle => ({
|
|
155
|
+
...identityWhere(lifecycle),
|
|
156
|
+
generation: lifecycle.generation,
|
|
157
|
+
status: 'provisioning',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const assertActivationOwnership = updatedRows => {
|
|
161
|
+
if (updatedRows !== 1) {
|
|
162
|
+
throw lifecycleError(409, 'Integration save was superseded by a newer lifecycle.');
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const touchActivation = async lifecycle => {
|
|
167
|
+
const [updatedRows] = await sqldb.IntegrationLifecycle.update({
|
|
168
|
+
updatedAt: new Date(),
|
|
169
|
+
}, {
|
|
170
|
+
where: activationWhere(lifecycle),
|
|
171
|
+
});
|
|
172
|
+
if (updatedRows === 1) return;
|
|
173
|
+
// MySQL reports zero changed rows when a sub-second heartbeat rounds to the
|
|
174
|
+
// same stored timestamp, so confirm the fenced row before treating it as lost.
|
|
175
|
+
const current = await sqldb.IntegrationLifecycle.findOne({
|
|
176
|
+
where: activationWhere(lifecycle),
|
|
177
|
+
});
|
|
178
|
+
if (!current) assertActivationOwnership(updatedRows);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const completeActivation = async lifecycle => {
|
|
182
|
+
const [updatedRows] = await sqldb.IntegrationLifecycle.update({
|
|
183
|
+
status: 'active',
|
|
184
|
+
artifacts: null,
|
|
185
|
+
lastError: null,
|
|
186
|
+
}, {
|
|
187
|
+
where: activationWhere(lifecycle),
|
|
188
|
+
});
|
|
189
|
+
assertActivationOwnership(updatedRows);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const failActivation = (lifecycle, error) => sqldb.IntegrationLifecycle.update({
|
|
193
|
+
status: 'failed_activation',
|
|
194
|
+
lastError: error.message,
|
|
195
|
+
}, {
|
|
196
|
+
where: {
|
|
197
|
+
...identityWhere(lifecycle),
|
|
198
|
+
generation: lifecycle.generation,
|
|
199
|
+
status: 'provisioning',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const beginDeletion = async ({
|
|
204
|
+
userId,
|
|
205
|
+
integrationId,
|
|
206
|
+
hint,
|
|
207
|
+
requestedBy,
|
|
208
|
+
}) => {
|
|
209
|
+
const where = identityWhere({ userId, integrationId, hint });
|
|
210
|
+
return sqldb.sequelize.transaction(async transaction => {
|
|
211
|
+
await sqldb.User.findOne({
|
|
212
|
+
where: { userId },
|
|
213
|
+
transaction,
|
|
214
|
+
lock: transaction.LOCK.UPDATE,
|
|
215
|
+
});
|
|
216
|
+
let lifecycle = await sqldb.IntegrationLifecycle.findOne({
|
|
217
|
+
where,
|
|
218
|
+
transaction,
|
|
219
|
+
lock: transaction.LOCK.UPDATE,
|
|
220
|
+
});
|
|
221
|
+
if (lifecycle && ['complete', 'external_cleanup'].includes(lifecycle.status)) {
|
|
222
|
+
return lifecycle;
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
lifecycle
|
|
226
|
+
&& lifecycle.status === 'provisioning'
|
|
227
|
+
&& !provisioningIsStale(lifecycle)
|
|
228
|
+
) {
|
|
229
|
+
throw lifecycleError(409, 'Integration save is still finishing. Retry removal shortly.');
|
|
230
|
+
}
|
|
231
|
+
if (!lifecycle) {
|
|
232
|
+
const credential = await sqldb.UserAppKey.findOne({
|
|
233
|
+
where,
|
|
234
|
+
transaction,
|
|
235
|
+
lock: transaction.LOCK.UPDATE,
|
|
236
|
+
});
|
|
237
|
+
if (!credential) throw lifecycleError(404, 'Integration credential not found.');
|
|
238
|
+
lifecycle = await sqldb.IntegrationLifecycle.create({
|
|
239
|
+
...where,
|
|
240
|
+
generation: 1,
|
|
241
|
+
status: 'deleting',
|
|
242
|
+
requestId: newRequestId(),
|
|
243
|
+
retryCount: 0,
|
|
244
|
+
requestedBy,
|
|
245
|
+
requestedAt: new Date(),
|
|
246
|
+
}, { transaction });
|
|
247
|
+
return lifecycle;
|
|
248
|
+
}
|
|
249
|
+
if (
|
|
250
|
+
['active', 'failed_activation'].includes(lifecycle.status)
|
|
251
|
+
|| (lifecycle.status === 'provisioning' && provisioningIsStale(lifecycle))
|
|
252
|
+
) {
|
|
253
|
+
// Deletion intentionally advances beyond a failed/stale activation. The catalog
|
|
254
|
+
// contract accepts a higher generation so an activation that committed remotely
|
|
255
|
+
// before TI2 crashed cannot resurrect the integration.
|
|
256
|
+
lifecycle.generation += 1;
|
|
257
|
+
lifecycle.requestId = newRequestId();
|
|
258
|
+
lifecycle.retryCount = 0;
|
|
259
|
+
lifecycle.requestedAt = new Date();
|
|
260
|
+
} else {
|
|
261
|
+
lifecycle.retryCount += 1;
|
|
262
|
+
}
|
|
263
|
+
lifecycle.status = 'deleting';
|
|
264
|
+
lifecycle.requestedBy = requestedBy;
|
|
265
|
+
lifecycle.lastError = null;
|
|
266
|
+
await lifecycle.save({ transaction });
|
|
267
|
+
return lifecycle;
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const cleanupSchedules = async ({
|
|
272
|
+
userId,
|
|
273
|
+
integrationId,
|
|
274
|
+
hint,
|
|
275
|
+
}) => {
|
|
276
|
+
const where = {
|
|
277
|
+
userId,
|
|
278
|
+
hint,
|
|
279
|
+
pluginName: integrationId,
|
|
280
|
+
};
|
|
281
|
+
return sqldb.sequelize.transaction(async transaction => {
|
|
282
|
+
const schedules = await sqldb.CronJobs.findAll({
|
|
283
|
+
where,
|
|
284
|
+
transaction,
|
|
285
|
+
lock: transaction.LOCK.UPDATE,
|
|
286
|
+
});
|
|
287
|
+
/* eslint-disable no-await-in-loop */
|
|
288
|
+
// Sequential removal keeps each Bull deletion paired with its locked database row.
|
|
289
|
+
for (const schedule of schedules) { // eslint-disable-line no-restricted-syntax
|
|
290
|
+
const references = await sqldb.CronJobs.findAll({
|
|
291
|
+
where: { bullJobId: schedule.bullJobId },
|
|
292
|
+
transaction,
|
|
293
|
+
lock: transaction.LOCK.UPDATE,
|
|
294
|
+
});
|
|
295
|
+
if (references.length === 1) await removeJob(schedule.bullJobId);
|
|
296
|
+
await schedule.destroy({ transaction });
|
|
297
|
+
}
|
|
298
|
+
/* eslint-enable no-await-in-loop */
|
|
299
|
+
return schedules.length;
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const cleanupCredentialAndSettings = ({ userId, integrationId, hint }) => (
|
|
304
|
+
sqldb.sequelize.transaction(async transaction => {
|
|
305
|
+
await sqldb.User.findOne({
|
|
306
|
+
where: { userId },
|
|
307
|
+
transaction,
|
|
308
|
+
lock: transaction.LOCK.UPDATE,
|
|
309
|
+
});
|
|
310
|
+
const credentials = await sqldb.UserAppKey.destroy({
|
|
311
|
+
where: identityWhere({ userId, integrationId, hint }),
|
|
312
|
+
transaction,
|
|
313
|
+
});
|
|
314
|
+
const remainingHints = await sqldb.UserAppKey.count({
|
|
315
|
+
where: { userId, integrationId },
|
|
316
|
+
transaction,
|
|
317
|
+
});
|
|
318
|
+
const settings = remainingHints === 0
|
|
319
|
+
? await sqldb.UserIntegrationSettings.destroy({
|
|
320
|
+
where: { userId, integrationId },
|
|
321
|
+
transaction,
|
|
322
|
+
})
|
|
323
|
+
: 0;
|
|
324
|
+
return { credentials, settings };
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const deleteIntegration = async ({
|
|
329
|
+
userId,
|
|
330
|
+
integrationId,
|
|
331
|
+
hint,
|
|
332
|
+
requestedBy,
|
|
333
|
+
deferCompletion = false,
|
|
334
|
+
}) => {
|
|
335
|
+
const lifecycle = await beginDeletion({
|
|
336
|
+
userId,
|
|
337
|
+
integrationId,
|
|
338
|
+
hint,
|
|
339
|
+
requestedBy,
|
|
340
|
+
});
|
|
341
|
+
if (['complete', 'external_cleanup'].includes(lifecycle.status)) {
|
|
342
|
+
return lifecycle.get({ plain: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const where = identityWhere({ userId, integrationId, hint });
|
|
346
|
+
let failureStage = 'schedule cleanup';
|
|
347
|
+
let observedArtifacts = lifecycle.artifacts || {};
|
|
348
|
+
try {
|
|
349
|
+
const cronJobs = await cleanupSchedules({ userId, integrationId, hint });
|
|
350
|
+
const previousArtifacts = { ...observedArtifacts };
|
|
351
|
+
delete previousArtifacts.failureStage;
|
|
352
|
+
observedArtifacts = {
|
|
353
|
+
...previousArtifacts,
|
|
354
|
+
cronJobs: Math.max(previousArtifacts.cronJobs || 0, cronJobs),
|
|
355
|
+
};
|
|
356
|
+
await sqldb.IntegrationLifecycle.update({ artifacts: observedArtifacts }, {
|
|
357
|
+
where: {
|
|
358
|
+
...where,
|
|
359
|
+
generation: lifecycle.generation,
|
|
360
|
+
status: { [Op.in]: ['deleting', 'failed'] },
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
failureStage = 'credential and settings cleanup';
|
|
364
|
+
const { credentials, settings } = await cleanupCredentialAndSettings({
|
|
365
|
+
userId,
|
|
366
|
+
integrationId,
|
|
367
|
+
hint,
|
|
368
|
+
});
|
|
369
|
+
observedArtifacts = {
|
|
370
|
+
...observedArtifacts,
|
|
371
|
+
credentials: Math.max(observedArtifacts.credentials || 0, credentials),
|
|
372
|
+
settings: Math.max(observedArtifacts.settings || 0, settings),
|
|
373
|
+
};
|
|
374
|
+
await sqldb.IntegrationLifecycle.update({ artifacts: observedArtifacts }, {
|
|
375
|
+
where: {
|
|
376
|
+
...where,
|
|
377
|
+
generation: lifecycle.generation,
|
|
378
|
+
status: { [Op.in]: ['deleting', 'failed'] },
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
failureStage = 'product catalog cleanup';
|
|
382
|
+
const catalog = await callCatalogLifecycle({
|
|
383
|
+
action: 'delete',
|
|
384
|
+
userId,
|
|
385
|
+
integrationId,
|
|
386
|
+
hint,
|
|
387
|
+
generation: lifecycle.generation,
|
|
388
|
+
});
|
|
389
|
+
if (catalog && catalog.status === 'superseded') {
|
|
390
|
+
throw lifecycleError(409, 'A newer product catalog lifecycle already exists.');
|
|
391
|
+
}
|
|
392
|
+
if (catalog && catalog.status === 'retry') {
|
|
393
|
+
throw lifecycleError(503, 'Product catalog cleanup is not ready and can be retried.');
|
|
394
|
+
}
|
|
395
|
+
if (!catalog || catalog.status !== 'deleted') {
|
|
396
|
+
throw lifecycleError(502, 'Product catalog cleanup returned an unexpected response.');
|
|
397
|
+
}
|
|
398
|
+
const artifacts = {
|
|
399
|
+
...observedArtifacts,
|
|
400
|
+
catalog,
|
|
401
|
+
};
|
|
402
|
+
const status = deferCompletion ? 'external_cleanup' : 'complete';
|
|
403
|
+
const [updatedRows] = await sqldb.IntegrationLifecycle.update({
|
|
404
|
+
status,
|
|
405
|
+
artifacts,
|
|
406
|
+
lastError: null,
|
|
407
|
+
}, {
|
|
408
|
+
where: {
|
|
409
|
+
...where,
|
|
410
|
+
generation: lifecycle.generation,
|
|
411
|
+
status: { [Op.in]: ['deleting', 'failed'] },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
if (updatedRows === 0) {
|
|
415
|
+
const persisted = await sqldb.IntegrationLifecycle.findOne({ where });
|
|
416
|
+
if (
|
|
417
|
+
persisted
|
|
418
|
+
&& persisted.generation === lifecycle.generation
|
|
419
|
+
&& ['complete', 'external_cleanup'].includes(persisted.status)
|
|
420
|
+
) {
|
|
421
|
+
return persisted.get({ plain: true });
|
|
422
|
+
}
|
|
423
|
+
throw lifecycleError(409, 'Integration cleanup state changed before completion.');
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...where,
|
|
427
|
+
generation: lifecycle.generation,
|
|
428
|
+
requestId: lifecycle.requestId,
|
|
429
|
+
requestedAt: lifecycle.requestedAt,
|
|
430
|
+
retryCount: lifecycle.retryCount,
|
|
431
|
+
status,
|
|
432
|
+
artifacts,
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
await sqldb.IntegrationLifecycle.update({
|
|
436
|
+
status: 'failed',
|
|
437
|
+
artifacts: { ...observedArtifacts, failureStage },
|
|
438
|
+
lastError: error.message,
|
|
439
|
+
}, {
|
|
440
|
+
where: {
|
|
441
|
+
...where,
|
|
442
|
+
generation: lifecycle.generation,
|
|
443
|
+
status: 'deleting',
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
if (error.status) throw error;
|
|
447
|
+
throw lifecycleError(
|
|
448
|
+
503,
|
|
449
|
+
`Integration cleanup stopped at ${failureStage} and can be retried: ${error.message}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const completeDeletion = async ({
|
|
455
|
+
userId,
|
|
456
|
+
integrationId,
|
|
457
|
+
hint,
|
|
458
|
+
generation,
|
|
459
|
+
requestId,
|
|
460
|
+
externalArtifacts,
|
|
461
|
+
}) => sqldb.sequelize.transaction(async transaction => {
|
|
462
|
+
const where = identityWhere({ userId, integrationId, hint });
|
|
463
|
+
const lifecycle = await sqldb.IntegrationLifecycle.findOne({
|
|
464
|
+
where,
|
|
465
|
+
transaction,
|
|
466
|
+
lock: transaction.LOCK.UPDATE,
|
|
467
|
+
});
|
|
468
|
+
if (!lifecycle || lifecycle.generation !== generation) {
|
|
469
|
+
throw lifecycleError(409, 'Integration cleanup generation no longer matches.');
|
|
470
|
+
}
|
|
471
|
+
if (lifecycle.requestId !== requestId) {
|
|
472
|
+
throw lifecycleError(409, 'Integration cleanup request no longer owns this generation.');
|
|
473
|
+
}
|
|
474
|
+
if (lifecycle.status === 'complete') return lifecycle.get({ plain: true });
|
|
475
|
+
if (lifecycle.status !== 'external_cleanup') {
|
|
476
|
+
throw lifecycleError(409, 'Integration cleanup is not ready to finalize.');
|
|
477
|
+
}
|
|
478
|
+
lifecycle.status = 'complete';
|
|
479
|
+
lifecycle.artifacts = {
|
|
480
|
+
...(lifecycle.artifacts || {}),
|
|
481
|
+
external: externalArtifacts || {},
|
|
482
|
+
};
|
|
483
|
+
lifecycle.lastError = null;
|
|
484
|
+
await lifecycle.save({ transaction });
|
|
485
|
+
return lifecycle.get({ plain: true });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
module.exports = {
|
|
489
|
+
activateCatalog,
|
|
490
|
+
completeActivation,
|
|
491
|
+
completeDeletion,
|
|
492
|
+
deleteIntegration,
|
|
493
|
+
failActivation,
|
|
494
|
+
prepareActivation,
|
|
495
|
+
touchActivation,
|
|
496
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* globals beforeEach describe expect it jest */
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const mockQueue = {
|
|
6
|
+
getJob: jest.fn(),
|
|
7
|
+
getRepeatableJobs: jest.fn(),
|
|
8
|
+
on: jest.fn(),
|
|
9
|
+
removeRepeatableByKey: jest.fn(),
|
|
10
|
+
};
|
|
11
|
+
const mockRedis = { del: jest.fn() };
|
|
12
|
+
|
|
13
|
+
jest.mock('bull', () => jest.fn(() => mockQueue));
|
|
14
|
+
jest.mock('ioredis', () => jest.fn(() => mockRedis));
|
|
15
|
+
|
|
16
|
+
jest.resetModules();
|
|
17
|
+
const { removeJob } = require('../queue');
|
|
18
|
+
|
|
19
|
+
const occurrenceId = ({
|
|
20
|
+
name, id, key, timestamp = 1700000000000,
|
|
21
|
+
}) => {
|
|
22
|
+
const namespace = crypto.createHash('md5').update(key).digest('hex');
|
|
23
|
+
const repeatHash = crypto.createHash('md5')
|
|
24
|
+
.update(`${name}${id ? `${id}:` : ':'}${namespace}`)
|
|
25
|
+
.digest('hex');
|
|
26
|
+
return `repeat:${repeatHash}:${timestamp}`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('removeJob', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
jest.clearAllMocks();
|
|
32
|
+
mockQueue.getJob.mockResolvedValue(null);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('removes only the repeat definition that owns the stored occurrence id', async () => {
|
|
36
|
+
const target = {
|
|
37
|
+
name: '__default__',
|
|
38
|
+
id: 'company-a-tourplan-desk-a',
|
|
39
|
+
key: '__default__:company-a-tourplan-desk-a:::0 9 * * *',
|
|
40
|
+
};
|
|
41
|
+
const sameCronOtherIntegration = {
|
|
42
|
+
name: '__default__',
|
|
43
|
+
id: 'company-a-tourplan-desk-b',
|
|
44
|
+
key: '__default__:company-a-tourplan-desk-b:::0 9 * * *',
|
|
45
|
+
};
|
|
46
|
+
mockQueue.getRepeatableJobs.mockResolvedValue([sameCronOtherIntegration, target]);
|
|
47
|
+
const jobId = occurrenceId(target);
|
|
48
|
+
|
|
49
|
+
await removeJob(jobId);
|
|
50
|
+
|
|
51
|
+
expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledTimes(1);
|
|
52
|
+
expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledWith(target.key);
|
|
53
|
+
expect(mockRedis.del).toHaveBeenCalledWith(jobId);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('treats an already-removed repeat definition as an idempotent success', async () => {
|
|
57
|
+
mockQueue.getRepeatableJobs.mockResolvedValue([]);
|
|
58
|
+
|
|
59
|
+
await expect(removeJob('repeat:missing:1700000000000')).resolves.toBeUndefined();
|
|
60
|
+
|
|
61
|
+
expect(mockQueue.removeRepeatableByKey).not.toHaveBeenCalled();
|
|
62
|
+
expect(mockRedis.del).toHaveBeenCalledWith('repeat:missing:1700000000000');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('removes a repeat definition referenced by a legacy custom job id', async () => {
|
|
66
|
+
const target = {
|
|
67
|
+
id: 'company-a-tourplan-desk-a',
|
|
68
|
+
key: '__default__:company-a-tourplan-desk-a:::0 9 * * *',
|
|
69
|
+
};
|
|
70
|
+
mockQueue.getRepeatableJobs.mockResolvedValue([target]);
|
|
71
|
+
|
|
72
|
+
await removeJob(target.id);
|
|
73
|
+
|
|
74
|
+
expect(mockQueue.removeRepeatableByKey).toHaveBeenCalledWith(target.key);
|
|
75
|
+
expect(mockRedis.del).toHaveBeenCalledWith(target.id);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/worker/queue.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const Queue = require('bull');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const R = require('ramda');
|
|
3
4
|
const Redis = require('ioredis');
|
|
4
|
-
|
|
5
|
-
const { env: { redisHost, redisPort } } = process;
|
|
5
|
+
|
|
6
6
|
const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
|
|
7
7
|
const itemsTTL = 3 * 60 * 60; // 3 hours
|
|
8
8
|
|
|
@@ -40,71 +40,60 @@ const addJob = async (payload, paramsParam) => {
|
|
|
40
40
|
removeOnComplete: true,
|
|
41
41
|
...params,
|
|
42
42
|
});
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
return id;
|
|
43
|
+
// Repeat jobs use an occurrence ID containing Bull's repeat-definition hash.
|
|
44
|
+
// Persisting it lets removeJob identify one exact definition without matching by cron.
|
|
45
|
+
return job.id;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const saveResult = async ({ id, resultValue }) => {
|
|
49
49
|
await redisResults.set(id, JSON.stringify(resultValue), 'EX', itemsTTL);
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
const removeJob = async
|
|
53
|
-
|
|
52
|
+
const removeJob = async jobId => {
|
|
53
|
+
if (!jobId) return;
|
|
54
|
+
// Bull occurrence IDs use the format 'repeat:<repeat-definition-hash>:<timestamp>'.
|
|
54
55
|
const jobIdParts = jobId.split(':');
|
|
55
56
|
const isRepeatJob = jobIdParts[0] === 'repeat';
|
|
56
57
|
|
|
57
58
|
if (isRepeatJob) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Wait for the queue to process the removals
|
|
85
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
86
|
-
|
|
87
|
-
// Clean up any remaining jobs
|
|
88
|
-
const remainingJobs = await queue.getJobs(['active', 'wait', 'delayed']);
|
|
89
|
-
for (const job of remainingJobs) {
|
|
90
|
-
if (job.opts.repeat && job.opts.jobId === jobId) {
|
|
91
|
-
await job.remove();
|
|
92
|
-
}
|
|
59
|
+
const job = await queue.getJob(jobId);
|
|
60
|
+
let repeatKey = R.path(['opts', 'repeat', 'key'], job);
|
|
61
|
+
if (!repeatKey) {
|
|
62
|
+
// Bull v4 does not expose occurrence-to-definition lookup. Recreate its
|
|
63
|
+
// internal hash only for legacy rows whose scheduled occurrence has expired.
|
|
64
|
+
const repeatHash = jobIdParts[1];
|
|
65
|
+
const repeatableJobs = await queue.getRepeatableJobs();
|
|
66
|
+
const repeatable = repeatableJobs.find(candidate => {
|
|
67
|
+
const candidateJobId = candidate.id ? `${candidate.id}:` : ':';
|
|
68
|
+
const namespace = crypto.createHash('md5').update(candidate.key).digest('hex');
|
|
69
|
+
const candidateHash = crypto.createHash('md5')
|
|
70
|
+
.update(`${candidate.name}${candidateJobId}${namespace}`)
|
|
71
|
+
.digest('hex');
|
|
72
|
+
return candidateHash === repeatHash;
|
|
73
|
+
});
|
|
74
|
+
repeatKey = repeatable && repeatable.key;
|
|
75
|
+
}
|
|
76
|
+
if (repeatKey) await queue.removeRepeatableByKey(repeatKey);
|
|
77
|
+
if (job) {
|
|
78
|
+
try {
|
|
79
|
+
await job.remove();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.message && !error.message.includes('not found')) throw error;
|
|
93
82
|
}
|
|
94
|
-
|
|
95
|
-
await redisResults.del(jobId);
|
|
96
|
-
return;
|
|
97
83
|
}
|
|
98
|
-
|
|
99
|
-
// If we couldn't find the repeatable job, just delete from the database
|
|
100
|
-
// This can happen if the job was already removed from Bull but still exists in our database
|
|
84
|
+
await redisResults.del(jobId);
|
|
101
85
|
return;
|
|
102
86
|
}
|
|
103
87
|
|
|
104
88
|
// If not a repeatable job, try to remove as a regular job
|
|
105
89
|
const job = await queue.getJob(jobId);
|
|
106
90
|
if (!job) {
|
|
107
|
-
|
|
91
|
+
// Older CronJobs rows stored opts.jobId instead of the repeat occurrence ID.
|
|
92
|
+
const repeatableJobs = await queue.getRepeatableJobs() || [];
|
|
93
|
+
const repeatable = repeatableJobs.find(candidate => candidate.id === jobId);
|
|
94
|
+
if (repeatable) await queue.removeRepeatableByKey(repeatable.key);
|
|
95
|
+
await redisResults.del(jobId);
|
|
96
|
+
return;
|
|
108
97
|
}
|
|
109
98
|
await job.remove();
|
|
110
99
|
await redisResults.del(jobId);
|