@quave/agenda-mongodb 1.0.0
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/LICENSE.md +25 -0
- package/README.md +273 -0
- package/dist/MongoBackend.d.ts +58 -0
- package/dist/MongoBackend.js +82 -0
- package/dist/MongoBackend.js.map +1 -0
- package/dist/MongoChangeStreamNotificationChannel.d.ts +96 -0
- package/dist/MongoChangeStreamNotificationChannel.js +248 -0
- package/dist/MongoChangeStreamNotificationChannel.js.map +1 -0
- package/dist/MongoJobLogger.d.ts +46 -0
- package/dist/MongoJobLogger.js +142 -0
- package/dist/MongoJobLogger.js.map +1 -0
- package/dist/MongoJobRepository.d.ts +84 -0
- package/dist/MongoJobRepository.js +713 -0
- package/dist/MongoJobRepository.js.map +1 -0
- package/dist/cjs/MongoBackend.d.ts +58 -0
- package/dist/cjs/MongoBackend.js +86 -0
- package/dist/cjs/MongoBackend.js.map +1 -0
- package/dist/cjs/MongoChangeStreamNotificationChannel.d.ts +96 -0
- package/dist/cjs/MongoChangeStreamNotificationChannel.js +255 -0
- package/dist/cjs/MongoChangeStreamNotificationChannel.js.map +1 -0
- package/dist/cjs/MongoJobLogger.d.ts +46 -0
- package/dist/cjs/MongoJobLogger.js +149 -0
- package/dist/cjs/MongoJobLogger.js.map +1 -0
- package/dist/cjs/MongoJobRepository.d.ts +84 -0
- package/dist/cjs/MongoJobRepository.js +720 -0
- package/dist/cjs/MongoJobRepository.js.map +1 -0
- package/dist/cjs/hasMongoProtocol.d.ts +1 -0
- package/dist/cjs/hasMongoProtocol.js +6 -0
- package/dist/cjs/hasMongoProtocol.js.map +1 -0
- package/dist/cjs/index.d.ts +39 -0
- package/dist/cjs/index.js +46 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/testing.d.ts +25 -0
- package/dist/cjs/testing.js +55 -0
- package/dist/cjs/testing.js.map +1 -0
- package/dist/cjs/types.d.ts +52 -0
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -0
- package/dist/hasMongoProtocol.d.ts +1 -0
- package/dist/hasMongoProtocol.js +2 -0
- package/dist/hasMongoProtocol.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +49 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
// Unprefixed builtin import: Meteor 2.x's module resolver predates the
|
|
2
|
+
// node: scheme, and require('node:...') needs Node 14.18+.
|
|
3
|
+
import assert from 'assert';
|
|
4
|
+
import debug from 'debug';
|
|
5
|
+
import { MongoClient, ObjectId } from 'mongodb';
|
|
6
|
+
import { toJobId, computeJobState } from '@quave/agenda';
|
|
7
|
+
import { hasMongoProtocol } from './hasMongoProtocol.js';
|
|
8
|
+
const log = debug('agenda:mongo:repository');
|
|
9
|
+
/**
|
|
10
|
+
* Escape special regex characters in a string to treat it as literal text
|
|
11
|
+
*/
|
|
12
|
+
function escapeRegex(str) {
|
|
13
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Check if a value is a plain object (not an array, date, or other non-plain type)
|
|
17
|
+
*/
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
if (value === null || typeof value !== 'object') {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const prototype = Object.getPrototypeOf(value);
|
|
23
|
+
return prototype === Object.prototype || prototype === null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Flatten a data filter object into MongoDB dot-notation keys.
|
|
27
|
+
* This enables partial matching on the data subdocument.
|
|
28
|
+
*
|
|
29
|
+
* Example:
|
|
30
|
+
* { searchField: "value", nested: { key: "v" } }
|
|
31
|
+
* becomes:
|
|
32
|
+
* { "data.searchField": "value", "data.nested.key": "v" }
|
|
33
|
+
*/
|
|
34
|
+
function flattenDataFilter(data, prefix = 'data') {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const [key, value] of Object.entries(data)) {
|
|
37
|
+
const fullKey = `${prefix}.${key}`;
|
|
38
|
+
if (isPlainObject(value)) {
|
|
39
|
+
Object.assign(result, flattenDataFilter(value, fullKey));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
result[fullKey] = value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function computeJobObj(job) {
|
|
48
|
+
return {
|
|
49
|
+
_id: toJobId(job._id.toHexString()),
|
|
50
|
+
name: job.name,
|
|
51
|
+
priority: job.priority,
|
|
52
|
+
nextRunAt: job.nextRunAt,
|
|
53
|
+
type: job.type,
|
|
54
|
+
data: job.data,
|
|
55
|
+
lockedAt: job.lockedAt,
|
|
56
|
+
lastFinishedAt: job.lastFinishedAt,
|
|
57
|
+
failedAt: job.failedAt || undefined,
|
|
58
|
+
failCount: job.failCount || undefined,
|
|
59
|
+
failReason: job.failReason || undefined,
|
|
60
|
+
repeatTimezone: job.repeatTimezone,
|
|
61
|
+
lastRunAt: job.lastRunAt,
|
|
62
|
+
repeatInterval: job.repeatInterval,
|
|
63
|
+
repeatAt: job.repeatAt,
|
|
64
|
+
disabled: job.disabled,
|
|
65
|
+
progress: job.progress,
|
|
66
|
+
unique: job.unique,
|
|
67
|
+
uniqueOpts: job.uniqueOpts,
|
|
68
|
+
lastModifiedBy: job.lastModifiedBy,
|
|
69
|
+
fork: job.fork,
|
|
70
|
+
debounceStartedAt: job.debounceStartedAt
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* @class
|
|
75
|
+
* MongoDB implementation of JobRepository
|
|
76
|
+
*/
|
|
77
|
+
export class MongoJobRepository {
|
|
78
|
+
constructor(connectOptions) {
|
|
79
|
+
this.connectOptions = connectOptions;
|
|
80
|
+
this.ownsConnection = false;
|
|
81
|
+
this.connectOptions.sort = this.connectOptions.sort || { nextRunAt: 'asc', priority: 'desc' };
|
|
82
|
+
// Track if we own the connection (i.e., we created it from a connection string)
|
|
83
|
+
this.ownsConnection = !('mongo' in connectOptions);
|
|
84
|
+
}
|
|
85
|
+
async createConnection() {
|
|
86
|
+
const { connectOptions } = this;
|
|
87
|
+
if ('mongo' in connectOptions) {
|
|
88
|
+
log('using passed in mongo connection');
|
|
89
|
+
return connectOptions.mongo;
|
|
90
|
+
}
|
|
91
|
+
if ('db' in connectOptions && connectOptions.db) {
|
|
92
|
+
log('using database config', connectOptions);
|
|
93
|
+
return this.database(connectOptions.db.address, connectOptions.db.options);
|
|
94
|
+
}
|
|
95
|
+
throw new Error('invalid db config, or db config not found');
|
|
96
|
+
}
|
|
97
|
+
async getJobById(id) {
|
|
98
|
+
const doc = await this.collection.findOne({ _id: new ObjectId(id) });
|
|
99
|
+
if (!doc)
|
|
100
|
+
return null;
|
|
101
|
+
// Convert MongoDB ObjectId to JobId
|
|
102
|
+
return computeJobObj(doc);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Convert a SortDirection value to MongoDB's numeric sort direction
|
|
106
|
+
*/
|
|
107
|
+
toMongoSortDirection(dir) {
|
|
108
|
+
return dir === 'asc' ? 1 : -1;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* findOneAndUpdate that tolerates both driver result shapes: driver 6+
|
|
112
|
+
* returns the document (or null) directly, while drivers 4/5 — e.g. the
|
|
113
|
+
* mongodb driver bundled with Meteor 2.x — return a ModifyResult wrapper
|
|
114
|
+
* with the document under `value`.
|
|
115
|
+
*/
|
|
116
|
+
async findOneAndUpdateDoc(filter, update, options) {
|
|
117
|
+
const result = options
|
|
118
|
+
? await this.collection.findOneAndUpdate(filter, update, options)
|
|
119
|
+
: await this.collection.findOneAndUpdate(filter, update);
|
|
120
|
+
if (result && typeof result === 'object' && 'lastErrorObject' in result) {
|
|
121
|
+
const modifyResult = result;
|
|
122
|
+
return modifyResult.value ?? null;
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Convert generic sort options to MongoDB sort
|
|
128
|
+
*/
|
|
129
|
+
toMongoSort(sort) {
|
|
130
|
+
if (!sort) {
|
|
131
|
+
return { nextRunAt: -1, lastRunAt: -1 };
|
|
132
|
+
}
|
|
133
|
+
const mongoSort = {};
|
|
134
|
+
if (sort.nextRunAt !== undefined)
|
|
135
|
+
mongoSort.nextRunAt = this.toMongoSortDirection(sort.nextRunAt);
|
|
136
|
+
if (sort.lastRunAt !== undefined)
|
|
137
|
+
mongoSort.lastRunAt = this.toMongoSortDirection(sort.lastRunAt);
|
|
138
|
+
if (sort.lastFinishedAt !== undefined)
|
|
139
|
+
mongoSort.lastFinishedAt = this.toMongoSortDirection(sort.lastFinishedAt);
|
|
140
|
+
if (sort.priority !== undefined)
|
|
141
|
+
mongoSort.priority = this.toMongoSortDirection(sort.priority);
|
|
142
|
+
if (sort.name !== undefined)
|
|
143
|
+
mongoSort.name = this.toMongoSortDirection(sort.name);
|
|
144
|
+
if (sort.data !== undefined)
|
|
145
|
+
mongoSort.data = this.toMongoSortDirection(sort.data);
|
|
146
|
+
return Object.keys(mongoSort).length > 0 ? mongoSort : { nextRunAt: -1, lastRunAt: -1 };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Query jobs with database-agnostic options.
|
|
150
|
+
* Handles state computation and filtering internally.
|
|
151
|
+
*/
|
|
152
|
+
async queryJobs(options = {}) {
|
|
153
|
+
const { name, names, state, id, ids, search, data, includeDisabled = true, sort, skip = 0, limit = 0 } = options;
|
|
154
|
+
const now = new Date();
|
|
155
|
+
// Build MongoDB query from options
|
|
156
|
+
const query = {};
|
|
157
|
+
// Validate name is a string to prevent query operator injection
|
|
158
|
+
if (name && typeof name === 'string') {
|
|
159
|
+
query.name = name;
|
|
160
|
+
}
|
|
161
|
+
else if (names && Array.isArray(names) && names.length > 0) {
|
|
162
|
+
// Filter to only valid strings
|
|
163
|
+
const validNames = names.filter((n) => typeof n === 'string');
|
|
164
|
+
if (validNames.length > 0) {
|
|
165
|
+
query.name = { $in: validNames };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (id && typeof id === 'string') {
|
|
169
|
+
try {
|
|
170
|
+
query._id = new ObjectId(id);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { jobs: [], total: 0 };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (ids && Array.isArray(ids) && ids.length > 0) {
|
|
177
|
+
try {
|
|
178
|
+
const validIds = ids.filter((i) => typeof i === 'string');
|
|
179
|
+
if (validIds.length > 0) {
|
|
180
|
+
query._id = { $in: validIds.map(i => new ObjectId(i)) };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return { jobs: [], total: 0 };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Validate search is a string and escape regex metacharacters
|
|
188
|
+
if (search && typeof search === 'string' && search.length > 0) {
|
|
189
|
+
query.name = { $regex: escapeRegex(search), $options: 'i' };
|
|
190
|
+
}
|
|
191
|
+
if (data !== undefined) {
|
|
192
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
193
|
+
Object.assign(query, flattenDataFilter(data));
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
query.data = data;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!includeDisabled) {
|
|
200
|
+
query.disabled = { $ne: true };
|
|
201
|
+
}
|
|
202
|
+
// Fetch jobs
|
|
203
|
+
const allJobs = await this.collection.find(query).sort(this.toMongoSort(sort)).toArray();
|
|
204
|
+
// Compute states and filter by state if specified
|
|
205
|
+
// Special handling for 'paused' state which is based on disabled field
|
|
206
|
+
let jobsWithState = allJobs
|
|
207
|
+
.map(job => {
|
|
208
|
+
const jobOb = computeJobObj(job);
|
|
209
|
+
return {
|
|
210
|
+
...jobOb,
|
|
211
|
+
state: computeJobState(jobOb, now)
|
|
212
|
+
};
|
|
213
|
+
})
|
|
214
|
+
.filter(job => {
|
|
215
|
+
if (!state)
|
|
216
|
+
return true;
|
|
217
|
+
if (state === 'paused')
|
|
218
|
+
return job.disabled === true;
|
|
219
|
+
return job.state === state;
|
|
220
|
+
});
|
|
221
|
+
// Apply pagination after state filtering
|
|
222
|
+
const total = jobsWithState.length;
|
|
223
|
+
if (limit > 0) {
|
|
224
|
+
jobsWithState = jobsWithState.slice(skip, skip + limit);
|
|
225
|
+
}
|
|
226
|
+
else if (skip > 0) {
|
|
227
|
+
jobsWithState = jobsWithState.slice(skip);
|
|
228
|
+
}
|
|
229
|
+
return { jobs: jobsWithState, total };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get overview statistics for jobs grouped by name.
|
|
233
|
+
* Returns counts of jobs in each state for each job name.
|
|
234
|
+
*/
|
|
235
|
+
async getJobsOverview() {
|
|
236
|
+
const now = new Date();
|
|
237
|
+
const names = await this.getDistinctJobNames();
|
|
238
|
+
const overviews = await Promise.all(names.map(async (name) => {
|
|
239
|
+
const jobs = await this.collection.find({ name }).toArray();
|
|
240
|
+
const overview = {
|
|
241
|
+
name,
|
|
242
|
+
total: jobs.length,
|
|
243
|
+
running: 0,
|
|
244
|
+
scheduled: 0,
|
|
245
|
+
queued: 0,
|
|
246
|
+
completed: 0,
|
|
247
|
+
failed: 0,
|
|
248
|
+
repeating: 0,
|
|
249
|
+
paused: 0
|
|
250
|
+
};
|
|
251
|
+
for (const job of jobs) {
|
|
252
|
+
const state = computeJobState(job, now);
|
|
253
|
+
overview[state]++;
|
|
254
|
+
if (job.disabled === true) {
|
|
255
|
+
overview.paused++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return overview;
|
|
259
|
+
}));
|
|
260
|
+
return overviews;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get all distinct job names
|
|
264
|
+
*/
|
|
265
|
+
async getDistinctJobNames() {
|
|
266
|
+
return this.collection.distinct('name');
|
|
267
|
+
}
|
|
268
|
+
async getQueueSize() {
|
|
269
|
+
return this.collection.countDocuments({ nextRunAt: { $lt: new Date() } });
|
|
270
|
+
}
|
|
271
|
+
async removeJobs(options) {
|
|
272
|
+
const query = {};
|
|
273
|
+
if (options.id) {
|
|
274
|
+
const idStr = String(options.id);
|
|
275
|
+
try {
|
|
276
|
+
query._id = new ObjectId(idStr);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (options.ids && Array.isArray(options.ids) && options.ids.length > 0) {
|
|
283
|
+
try {
|
|
284
|
+
query._id = { $in: options.ids.map(id => new ObjectId(String(id))) };
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Validate name is a string to prevent query operator injection
|
|
291
|
+
if (options.name && typeof options.name === 'string') {
|
|
292
|
+
query.name = options.name;
|
|
293
|
+
}
|
|
294
|
+
else if (options.names && Array.isArray(options.names) && options.names.length > 0) {
|
|
295
|
+
const validNames = options.names.filter((n) => typeof n === 'string');
|
|
296
|
+
if (validNames.length > 0) {
|
|
297
|
+
query.name = { $in: validNames };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
else if (options.notNames && Array.isArray(options.notNames) && options.notNames.length > 0) {
|
|
301
|
+
const validNotNames = options.notNames.filter((n) => typeof n === 'string');
|
|
302
|
+
if (validNotNames.length > 0) {
|
|
303
|
+
query.name = { $nin: validNotNames };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (options.data !== undefined) {
|
|
307
|
+
if (options.data !== null && typeof options.data === 'object' && !Array.isArray(options.data)) {
|
|
308
|
+
Object.assign(query, flattenDataFilter(options.data));
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
query.data = options.data;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// If no criteria provided, don't delete anything
|
|
315
|
+
if (Object.keys(query).length === 0) {
|
|
316
|
+
log('removeJobs: skipping deleteMany without query', query);
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
const result = await this.collection.deleteMany(query);
|
|
320
|
+
return result.deletedCount;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Build a MongoDB filter from RemoveJobsOptions
|
|
324
|
+
*/
|
|
325
|
+
buildFilterFromOptions(options) {
|
|
326
|
+
const query = {};
|
|
327
|
+
if (options.id) {
|
|
328
|
+
const idStr = String(options.id);
|
|
329
|
+
try {
|
|
330
|
+
query._id = new ObjectId(idStr);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if (options.ids && Array.isArray(options.ids) && options.ids.length > 0) {
|
|
337
|
+
try {
|
|
338
|
+
query._id = { $in: options.ids.map(id => new ObjectId(String(id))) };
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Validate name is a string to prevent query operator injection
|
|
345
|
+
if (options.name && typeof options.name === 'string') {
|
|
346
|
+
query.name = options.name;
|
|
347
|
+
}
|
|
348
|
+
else if (options.names && Array.isArray(options.names) && options.names.length > 0) {
|
|
349
|
+
const validNames = options.names.filter((n) => typeof n === 'string');
|
|
350
|
+
if (validNames.length > 0) {
|
|
351
|
+
query.name = { $in: validNames };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (options.notNames && Array.isArray(options.notNames) && options.notNames.length > 0) {
|
|
355
|
+
const validNotNames = options.notNames.filter((n) => typeof n === 'string');
|
|
356
|
+
if (validNotNames.length > 0) {
|
|
357
|
+
query.name = { $nin: validNotNames };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (options.data !== undefined) {
|
|
361
|
+
if (options.data !== null && typeof options.data === 'object' && !Array.isArray(options.data)) {
|
|
362
|
+
Object.assign(query, flattenDataFilter(options.data));
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
query.data = options.data;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// If no criteria provided, return null to indicate no operation
|
|
369
|
+
if (Object.keys(query).length === 0) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
return query;
|
|
373
|
+
}
|
|
374
|
+
async disableJobs(options) {
|
|
375
|
+
const query = this.buildFilterFromOptions(options);
|
|
376
|
+
if (!query) {
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
const result = await this.collection.updateMany(query, { $set: { disabled: true } });
|
|
380
|
+
return result.modifiedCount;
|
|
381
|
+
}
|
|
382
|
+
async enableJobs(options) {
|
|
383
|
+
const query = this.buildFilterFromOptions(options);
|
|
384
|
+
if (!query) {
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
const result = await this.collection.updateMany(query, { $set: { disabled: false } });
|
|
388
|
+
return result.modifiedCount;
|
|
389
|
+
}
|
|
390
|
+
async purgeAllJobs() {
|
|
391
|
+
const result = await this.collection.deleteMany({});
|
|
392
|
+
return result.deletedCount;
|
|
393
|
+
}
|
|
394
|
+
async unlockJob(job) {
|
|
395
|
+
if (!job._id)
|
|
396
|
+
return;
|
|
397
|
+
// only unlock jobs which are not currently processed (nextRunAt is not null)
|
|
398
|
+
await this.collection.updateOne({ _id: new ObjectId(job._id.toString()), nextRunAt: { $ne: null } }, { $unset: { lockedAt: true } });
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Internal method to unlock jobs so that they can be re-run
|
|
402
|
+
*/
|
|
403
|
+
async unlockJobs(jobIds) {
|
|
404
|
+
if (jobIds.length === 0)
|
|
405
|
+
return;
|
|
406
|
+
const objectIds = jobIds.map(id => new ObjectId(id.toString()));
|
|
407
|
+
// Unlock jobs by ID regardless of nextRunAt status.
|
|
408
|
+
// When a one-time job starts running, nextRunAt becomes null,
|
|
409
|
+
// but we still want to unlock it when stop() is called.
|
|
410
|
+
await this.collection.updateMany({ _id: { $in: objectIds } }, { $unset: { lockedAt: true } });
|
|
411
|
+
}
|
|
412
|
+
async lockJob(job, options) {
|
|
413
|
+
if (!job._id)
|
|
414
|
+
return undefined;
|
|
415
|
+
// Query to run against collection to see if we need to lock it
|
|
416
|
+
const criteria = {
|
|
417
|
+
_id: new ObjectId(job._id.toString()),
|
|
418
|
+
name: job.name,
|
|
419
|
+
lockedAt: null,
|
|
420
|
+
nextRunAt: job.nextRunAt,
|
|
421
|
+
disabled: { $ne: true }
|
|
422
|
+
};
|
|
423
|
+
// Update / options for the MongoDB query
|
|
424
|
+
const update = {
|
|
425
|
+
$set: { lockedAt: new Date(), lastModifiedBy: options?.lastModifiedBy }
|
|
426
|
+
};
|
|
427
|
+
const findOptions = {
|
|
428
|
+
returnDocument: 'after',
|
|
429
|
+
sort: this.connectOptions.sort
|
|
430
|
+
};
|
|
431
|
+
// Lock the job in MongoDB!
|
|
432
|
+
const result = await this.findOneAndUpdateDoc(criteria, update, findOptions);
|
|
433
|
+
if (!result)
|
|
434
|
+
return undefined;
|
|
435
|
+
// Convert MongoDB ObjectId to JobId
|
|
436
|
+
return computeJobObj(result);
|
|
437
|
+
}
|
|
438
|
+
async getNextJobToRun(jobName, nextScanAt, lockDeadline, now, options) {
|
|
439
|
+
const lockTime = now ?? new Date();
|
|
440
|
+
/**
|
|
441
|
+
* Query used to find job to run
|
|
442
|
+
*/
|
|
443
|
+
const JOB_PROCESS_WHERE_QUERY = {
|
|
444
|
+
name: jobName,
|
|
445
|
+
disabled: { $ne: true },
|
|
446
|
+
$or: [
|
|
447
|
+
{
|
|
448
|
+
lockedAt: { $eq: null },
|
|
449
|
+
nextRunAt: { $lte: nextScanAt }
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
lockedAt: { $lte: lockDeadline }
|
|
453
|
+
}
|
|
454
|
+
]
|
|
455
|
+
};
|
|
456
|
+
/**
|
|
457
|
+
* Query used to set a job as locked
|
|
458
|
+
*/
|
|
459
|
+
const JOB_PROCESS_SET_QUERY = {
|
|
460
|
+
$set: { lockedAt: lockTime, lastModifiedBy: options?.lastModifiedBy }
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* Query used to affect what gets returned
|
|
464
|
+
*/
|
|
465
|
+
const JOB_RETURN_QUERY = {
|
|
466
|
+
returnDocument: 'after',
|
|
467
|
+
sort: this.connectOptions.sort
|
|
468
|
+
};
|
|
469
|
+
// Find ONE and ONLY ONE job and set the 'lockedAt' time so that job begins to be processed
|
|
470
|
+
const result = await this.findOneAndUpdateDoc(JOB_PROCESS_WHERE_QUERY, JOB_PROCESS_SET_QUERY, JOB_RETURN_QUERY);
|
|
471
|
+
if (!result)
|
|
472
|
+
return undefined;
|
|
473
|
+
// Convert MongoDB ObjectId to JobId
|
|
474
|
+
return computeJobObj(result);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Get the underlying MongoDB Db instance (for use by job logger).
|
|
478
|
+
*/
|
|
479
|
+
getDb() {
|
|
480
|
+
if (!this._db) {
|
|
481
|
+
throw new Error('Not connected');
|
|
482
|
+
}
|
|
483
|
+
return this._db;
|
|
484
|
+
}
|
|
485
|
+
async connect() {
|
|
486
|
+
const db = await this.createConnection();
|
|
487
|
+
this._db = db;
|
|
488
|
+
log('successful connection to MongoDB', db.options);
|
|
489
|
+
const collection = this.connectOptions.db?.collection || 'agendaJobs';
|
|
490
|
+
this.collection = db.collection(collection);
|
|
491
|
+
if (log.enabled) {
|
|
492
|
+
log(`connected with collection: ${collection}, collection size: ${typeof this.collection.estimatedDocumentCount === 'function'
|
|
493
|
+
? await this.collection.estimatedDocumentCount()
|
|
494
|
+
: '?'}`);
|
|
495
|
+
}
|
|
496
|
+
if (this.connectOptions.ensureIndex ?? true) {
|
|
497
|
+
log('attempting index creation');
|
|
498
|
+
try {
|
|
499
|
+
// Index directions must be numeric; connectOptions.sort uses
|
|
500
|
+
// 'asc'/'desc' (valid for queries, rejected by createIndex).
|
|
501
|
+
const sortIndexSpec = {};
|
|
502
|
+
for (const [key, dir] of Object.entries(this.connectOptions.sort ?? {})) {
|
|
503
|
+
sortIndexSpec[key] = this.toMongoSortDirection(dir);
|
|
504
|
+
}
|
|
505
|
+
const result = await this.collection.createIndex({
|
|
506
|
+
name: 1,
|
|
507
|
+
...sortIndexSpec,
|
|
508
|
+
priority: -1,
|
|
509
|
+
lockedAt: 1,
|
|
510
|
+
nextRunAt: 1,
|
|
511
|
+
disabled: 1
|
|
512
|
+
}, { name: 'findAndLockNextJobIndex' });
|
|
513
|
+
log('index succesfully created', result);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
log('db index creation failed', error);
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async database(url, options) {
|
|
522
|
+
let connectionString = url;
|
|
523
|
+
if (!hasMongoProtocol(connectionString)) {
|
|
524
|
+
connectionString = `mongodb://${connectionString}`;
|
|
525
|
+
}
|
|
526
|
+
this.mongoClient = await MongoClient.connect(connectionString, {
|
|
527
|
+
...options
|
|
528
|
+
});
|
|
529
|
+
return this.mongoClient.db();
|
|
530
|
+
}
|
|
531
|
+
async disconnect() {
|
|
532
|
+
if (this.ownsConnection && this.mongoClient) {
|
|
533
|
+
log('closing owned MongoDB connection');
|
|
534
|
+
await this.mongoClient.close();
|
|
535
|
+
this.mongoClient = undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async saveJobState(job, options) {
|
|
539
|
+
if (!job._id) {
|
|
540
|
+
throw new Error('Cannot save job state without job ID');
|
|
541
|
+
}
|
|
542
|
+
const id = new ObjectId(job._id.toString());
|
|
543
|
+
const $set = {
|
|
544
|
+
lockedAt: (job.lockedAt && new Date(job.lockedAt)) || undefined,
|
|
545
|
+
nextRunAt: (job.nextRunAt && new Date(job.nextRunAt)) || undefined,
|
|
546
|
+
lastRunAt: (job.lastRunAt && new Date(job.lastRunAt)) || undefined,
|
|
547
|
+
progress: job.progress,
|
|
548
|
+
failReason: job.failReason,
|
|
549
|
+
failCount: job.failCount,
|
|
550
|
+
failedAt: job.failedAt && new Date(job.failedAt),
|
|
551
|
+
lastFinishedAt: (job.lastFinishedAt && new Date(job.lastFinishedAt)) || undefined,
|
|
552
|
+
lastModifiedBy: options?.lastModifiedBy
|
|
553
|
+
};
|
|
554
|
+
log('[job %s] save job state: \n%O', job._id, $set);
|
|
555
|
+
const result = await this.collection.updateOne({ _id: id, name: job.name }, {
|
|
556
|
+
$set
|
|
557
|
+
});
|
|
558
|
+
if (!result.acknowledged || result.matchedCount !== 1) {
|
|
559
|
+
throw new Error(`job ${job._id} (name: ${job.name}) cannot be updated in the database, maybe it does not exist anymore?`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Save a job to the database (insert or update)
|
|
564
|
+
* @param job Job parameters to save
|
|
565
|
+
* @returns The saved job parameters with ID
|
|
566
|
+
*/
|
|
567
|
+
async saveJob(job, options) {
|
|
568
|
+
try {
|
|
569
|
+
log('attempting to save a job');
|
|
570
|
+
// Extract the ID and unique fields (not persisted directly)
|
|
571
|
+
const { _id, unique, uniqueOpts, ...props } = job;
|
|
572
|
+
// Add lastModifiedBy
|
|
573
|
+
const propsWithModifier = {
|
|
574
|
+
...props,
|
|
575
|
+
lastModifiedBy: options?.lastModifiedBy
|
|
576
|
+
};
|
|
577
|
+
log('[job %s] set job props: \n%O', _id, propsWithModifier);
|
|
578
|
+
// Grab current time and set default query options for MongoDB
|
|
579
|
+
const now = new Date();
|
|
580
|
+
const protect = {};
|
|
581
|
+
let update = { $set: propsWithModifier };
|
|
582
|
+
log('current time stored as %s', now.toISOString());
|
|
583
|
+
// If the job already had an ID, then update the properties of the job
|
|
584
|
+
if (_id) {
|
|
585
|
+
log('job already has _id, calling findOneAndUpdate() using _id as query');
|
|
586
|
+
const result = await this.findOneAndUpdateDoc({ _id: new ObjectId(_id.toString()), name: props.name }, update, { returnDocument: 'after' });
|
|
587
|
+
if (!result) {
|
|
588
|
+
// Job was removed, return original data unchanged
|
|
589
|
+
log('job %s was not found for update, returning original data', _id);
|
|
590
|
+
return job;
|
|
591
|
+
}
|
|
592
|
+
return computeJobObj(result);
|
|
593
|
+
}
|
|
594
|
+
if (props.type === 'single') {
|
|
595
|
+
// Job type set to 'single' so...
|
|
596
|
+
log('job with type of "single" found');
|
|
597
|
+
// If the nextRunAt time is older than the current time, "protect" that property
|
|
598
|
+
if (props.nextRunAt && props.nextRunAt <= now) {
|
|
599
|
+
log('job has a scheduled nextRunAt time, protecting that field from upsert');
|
|
600
|
+
protect.nextRunAt = props.nextRunAt;
|
|
601
|
+
delete propsWithModifier.nextRunAt;
|
|
602
|
+
}
|
|
603
|
+
// If we have things to protect, set them in MongoDB using $setOnInsert
|
|
604
|
+
if (Object.keys(protect).length > 0) {
|
|
605
|
+
update.$setOnInsert = protect;
|
|
606
|
+
}
|
|
607
|
+
// Try an upsert - ensures only one job of this name exists
|
|
608
|
+
log(`calling findOneAndUpdate(${props.name}) with job name and type of "single" as query`);
|
|
609
|
+
const result = await this.findOneAndUpdateDoc({
|
|
610
|
+
name: props.name,
|
|
611
|
+
type: 'single'
|
|
612
|
+
}, update, {
|
|
613
|
+
upsert: true,
|
|
614
|
+
returnDocument: 'after'
|
|
615
|
+
});
|
|
616
|
+
log(`findOneAndUpdate(${props.name}) with type "single" completed`);
|
|
617
|
+
assert(result, 'findOneAndUpdate with upsert should always return a document');
|
|
618
|
+
return computeJobObj(result);
|
|
619
|
+
}
|
|
620
|
+
if (unique) {
|
|
621
|
+
// Upsert based on the 'unique' query object
|
|
622
|
+
const query = { ...unique };
|
|
623
|
+
query.name = props.name;
|
|
624
|
+
const debounce = uniqueOpts?.debounce;
|
|
625
|
+
if (uniqueOpts?.insertOnly && !debounce) {
|
|
626
|
+
update = { $setOnInsert: propsWithModifier };
|
|
627
|
+
}
|
|
628
|
+
// Handle debounce logic
|
|
629
|
+
if (debounce) {
|
|
630
|
+
log('handling debounce with delay: %dms, strategy: %s', debounce.delay, debounce.strategy || 'trailing');
|
|
631
|
+
// First, check if a matching job already exists
|
|
632
|
+
const existingJob = await this.collection.findOne(query);
|
|
633
|
+
if (existingJob) {
|
|
634
|
+
// Job exists - apply debounce logic
|
|
635
|
+
const debounceStartedAt = existingJob.debounceStartedAt ?? now;
|
|
636
|
+
const timeSinceStart = now.getTime() - debounceStartedAt.getTime();
|
|
637
|
+
if (debounce.strategy === 'leading') {
|
|
638
|
+
// Leading: keep original nextRunAt, just update data
|
|
639
|
+
// Don't change nextRunAt - the job runs on its original schedule
|
|
640
|
+
log('leading debounce: keeping original nextRunAt, updating data only');
|
|
641
|
+
const leadingUpdate = {
|
|
642
|
+
$set: {
|
|
643
|
+
...propsWithModifier,
|
|
644
|
+
nextRunAt: existingJob.nextRunAt, // Preserve original
|
|
645
|
+
debounceStartedAt: debounceStartedAt
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
const result = await this.findOneAndUpdateDoc(query, leadingUpdate, {
|
|
649
|
+
returnDocument: 'after'
|
|
650
|
+
});
|
|
651
|
+
assert(result, 'findOneAndUpdate should return a document');
|
|
652
|
+
return computeJobObj(result);
|
|
653
|
+
}
|
|
654
|
+
// Trailing (default): push nextRunAt forward
|
|
655
|
+
let newNextRunAt;
|
|
656
|
+
// Check maxWait - force execution if exceeded
|
|
657
|
+
if (debounce.maxWait && timeSinceStart >= debounce.maxWait) {
|
|
658
|
+
log('maxWait exceeded (%dms >= %dms), forcing immediate execution', timeSinceStart, debounce.maxWait);
|
|
659
|
+
newNextRunAt = now;
|
|
660
|
+
// Reset debounceStartedAt for the next cycle
|
|
661
|
+
propsWithModifier.debounceStartedAt = undefined;
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
// Normal debounce: schedule for delay ms from now
|
|
665
|
+
newNextRunAt = new Date(now.getTime() + debounce.delay);
|
|
666
|
+
propsWithModifier.debounceStartedAt = debounceStartedAt;
|
|
667
|
+
log('trailing debounce: rescheduling to %s', newNextRunAt.toISOString());
|
|
668
|
+
}
|
|
669
|
+
propsWithModifier.nextRunAt = newNextRunAt;
|
|
670
|
+
update = { $set: propsWithModifier };
|
|
671
|
+
const result = await this.findOneAndUpdateDoc(query, update, {
|
|
672
|
+
returnDocument: 'after'
|
|
673
|
+
});
|
|
674
|
+
assert(result, 'findOneAndUpdate should return a document');
|
|
675
|
+
return computeJobObj(result);
|
|
676
|
+
}
|
|
677
|
+
// No existing job - this is a new job
|
|
678
|
+
if (debounce.strategy === 'leading') {
|
|
679
|
+
// Leading: execute immediately (keep nextRunAt as-is)
|
|
680
|
+
log('leading debounce: new job, executing immediately');
|
|
681
|
+
propsWithModifier.debounceStartedAt = now;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
// Trailing: schedule after delay
|
|
685
|
+
const newNextRunAt = new Date(now.getTime() + debounce.delay);
|
|
686
|
+
propsWithModifier.nextRunAt = newNextRunAt;
|
|
687
|
+
propsWithModifier.debounceStartedAt = now;
|
|
688
|
+
log('trailing debounce: new job, scheduling for %s', newNextRunAt.toISOString());
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
log('calling findOneAndUpdate() with unique object as query: \n%O', query);
|
|
692
|
+
const result = await this.findOneAndUpdateDoc(query, update, {
|
|
693
|
+
upsert: true,
|
|
694
|
+
returnDocument: 'after'
|
|
695
|
+
});
|
|
696
|
+
assert(result, 'findOneAndUpdate with upsert should always return a document');
|
|
697
|
+
return computeJobObj(result);
|
|
698
|
+
}
|
|
699
|
+
// Insert new job
|
|
700
|
+
log('using default behavior, inserting new job via insertOne() with props that were set: \n%O', propsWithModifier);
|
|
701
|
+
const result = await this.collection.insertOne(propsWithModifier);
|
|
702
|
+
return computeJobObj({
|
|
703
|
+
_id: result.insertedId,
|
|
704
|
+
...propsWithModifier
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
log('saveJob() received an error, job was not updated/created');
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=MongoJobRepository.js.map
|