@cedarjs/jobs 7.0.1-next.314 → 7.0.1-next.466
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/dist/adapters/BaseAdapter/BaseAdapter.d.ts +155 -10
- package/dist/adapters/BaseAdapter/BaseAdapter.d.ts.map +1 -1
- package/dist/adapters/BaseAdapter/BaseAdapter.js +6 -0
- package/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts +68 -3
- package/dist/adapters/PrismaAdapter/PrismaAdapter.d.ts.map +1 -1
- package/dist/adapters/PrismaAdapter/PrismaAdapter.js +411 -70
- package/dist/adapters/PrismaAdapter/errors.d.ts +14 -0
- package/dist/adapters/PrismaAdapter/errors.d.ts.map +1 -1
- package/dist/adapters/PrismaAdapter/errors.js +17 -1
- package/dist/bins/cedar-jobs-worker.d.ts +11 -0
- package/dist/bins/cedar-jobs-worker.d.ts.map +1 -1
- package/dist/bins/cedar-jobs-worker.js +12 -1
- package/dist/bins/cedar-jobs.d.ts +25 -1
- package/dist/bins/cedar-jobs.d.ts.map +1 -1
- package/dist/bins/cedar-jobs.js +104 -3
- package/dist/consts.d.ts +18 -0
- package/dist/consts.d.ts.map +1 -1
- package/dist/consts.js +9 -1
- package/dist/core/Executor.d.ts +15 -1
- package/dist/core/Executor.d.ts.map +1 -1
- package/dist/core/Executor.js +90 -30
- package/dist/core/JobManager.d.ts +77 -1
- package/dist/core/JobManager.d.ts.map +1 -1
- package/dist/core/JobManager.js +210 -1
- package/dist/core/Scheduler.d.ts +1 -1
- package/dist/core/Scheduler.d.ts.map +1 -1
- package/dist/core/Scheduler.js +22 -5
- package/dist/core/Worker.d.ts +8 -0
- package/dist/core/Worker.d.ts.map +1 -1
- package/dist/core/Worker.js +49 -3
- package/dist/errors.d.ts +54 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +60 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/loaders.d.ts +29 -0
- package/dist/loaders.d.ts.map +1 -1
- package/dist/loaders.js +48 -1
- package/dist/types.d.ts +109 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/util.d.ts +6 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +6 -1
- package/package.json +14 -9
|
@@ -4,8 +4,19 @@ import {
|
|
|
4
4
|
DEFAULT_MODEL_NAME,
|
|
5
5
|
MAX_RUNTIME_GRACE_PERIOD
|
|
6
6
|
} from "../../consts.js";
|
|
7
|
+
import {
|
|
8
|
+
CedarJSJobsError,
|
|
9
|
+
JobIdNotFoundError,
|
|
10
|
+
JobNotActiveError
|
|
11
|
+
} from "../../errors.js";
|
|
12
|
+
import { nextCronRunAt } from "../../util.js";
|
|
7
13
|
import { BaseAdapter } from "../BaseAdapter/BaseAdapter.js";
|
|
8
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
KeyColumnMissingError,
|
|
16
|
+
ModelNameError,
|
|
17
|
+
PausedAtColumnMissingError
|
|
18
|
+
} from "./errors.js";
|
|
19
|
+
const MAX_KEY_RETRIES = 5;
|
|
9
20
|
function isPrismaAdapterDelegate(value) {
|
|
10
21
|
const candidate = value;
|
|
11
22
|
return candidate !== null && typeof candidate === "object" && typeof candidate.findFirst === "function" && typeof candidate.updateMany === "function" && typeof candidate.delete === "function" && typeof candidate.update === "function" && typeof candidate.create === "function" && typeof candidate.deleteMany === "function";
|
|
@@ -27,11 +38,34 @@ function getActiveProvider(db) {
|
|
|
27
38
|
}
|
|
28
39
|
return provider;
|
|
29
40
|
}
|
|
41
|
+
function isUniqueViolation(e) {
|
|
42
|
+
return typeof e === "object" && e !== null && "code" in e && e.code === "P2002";
|
|
43
|
+
}
|
|
44
|
+
function isMissingKeyColumn(e) {
|
|
45
|
+
return e instanceof Error && e.name === "PrismaClientValidationError" && /Unknown (argument|field) `key`/.test(e.message);
|
|
46
|
+
}
|
|
47
|
+
function releaseKey(job) {
|
|
48
|
+
return job.key != null ? { key: null } : {};
|
|
49
|
+
}
|
|
50
|
+
function rowToJob(row) {
|
|
51
|
+
const { name, path, args } = JSON.parse(row.handler);
|
|
52
|
+
return { ...row, name, path, args };
|
|
53
|
+
}
|
|
54
|
+
function isMissingPausedAtColumn(e) {
|
|
55
|
+
return e instanceof Error && e.name === "PrismaClientValidationError" && /Unknown (argument|field) `pausedAt`/.test(e.message);
|
|
56
|
+
}
|
|
30
57
|
class PrismaAdapter extends BaseAdapter {
|
|
58
|
+
supportsUniqueKeys = true;
|
|
31
59
|
db;
|
|
32
60
|
model;
|
|
33
61
|
accessor;
|
|
34
62
|
provider;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the model has a `pausedAt` column, so `find()` has to leave out
|
|
65
|
+
* paused jobs. Unknown until the first `find()`, which finds out by
|
|
66
|
+
* filtering on the column
|
|
67
|
+
*/
|
|
68
|
+
#hasPausedAtColumn;
|
|
35
69
|
constructor(options) {
|
|
36
70
|
super(options);
|
|
37
71
|
this.db = options.db;
|
|
@@ -54,92 +88,127 @@ class PrismaAdapter extends BaseAdapter {
|
|
|
54
88
|
const maxRuntimeExpire = new Date(
|
|
55
89
|
(/* @__PURE__ */ new Date()).getTime() - ((maxRuntime || DEFAULT_MAX_RUNTIME) + MAX_RUNTIME_GRACE_PERIOD) * 1e3
|
|
56
90
|
);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
lt: maxRuntimeExpire
|
|
70
|
-
}
|
|
91
|
+
const conditions = (withPausedAt) => [
|
|
92
|
+
{
|
|
93
|
+
OR: [
|
|
94
|
+
{
|
|
95
|
+
AND: [
|
|
96
|
+
{ runAt: { lte: /* @__PURE__ */ new Date() } },
|
|
97
|
+
{
|
|
98
|
+
OR: [
|
|
99
|
+
{ lockedAt: null },
|
|
100
|
+
{
|
|
101
|
+
lockedAt: {
|
|
102
|
+
lt: maxRuntimeExpire
|
|
71
103
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const job = await this.accessor.findFirst({
|
|
89
|
-
select: { id: true, attempts: true },
|
|
90
|
-
where: whereWithQueue,
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
{ lockedBy: processName }
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
{ failedAt: null },
|
|
113
|
+
...withPausedAt ? [{ pausedAt: null }] : [],
|
|
114
|
+
// If queues is ['*'] then skip, otherwise add a WHERE...IN for the
|
|
115
|
+
// array of queue names
|
|
116
|
+
...queues.length > 1 || queues[0] !== "*" ? [{ queue: { in: queues } }] : []
|
|
117
|
+
];
|
|
118
|
+
const findCandidate = (withPausedAt) => this.accessor.findFirst({
|
|
119
|
+
where: { AND: conditions(withPausedAt) },
|
|
91
120
|
orderBy: [{ priority: "asc" }, { runAt: "asc" }],
|
|
92
121
|
take: 1
|
|
93
122
|
});
|
|
123
|
+
let job;
|
|
124
|
+
if (this.#hasPausedAtColumn === false) {
|
|
125
|
+
job = await findCandidate(false);
|
|
126
|
+
} else {
|
|
127
|
+
try {
|
|
128
|
+
job = await findCandidate(true);
|
|
129
|
+
this.#hasPausedAtColumn = true;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (!isMissingPausedAtColumn(e)) {
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
this.#hasPausedAtColumn = false;
|
|
135
|
+
job = await findCandidate(false);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
94
138
|
if (job) {
|
|
95
|
-
const
|
|
96
|
-
AND: [...
|
|
97
|
-
}
|
|
139
|
+
const whereWithId = {
|
|
140
|
+
AND: [...conditions(this.#hasPausedAtColumn === true), { id: job.id }]
|
|
141
|
+
};
|
|
98
142
|
const { count } = await this.accessor.updateMany({
|
|
99
|
-
where:
|
|
143
|
+
where: whereWithId,
|
|
100
144
|
data: {
|
|
101
145
|
lockedAt: /* @__PURE__ */ new Date(),
|
|
102
146
|
lockedBy: processName,
|
|
103
|
-
attempts: job.attempts + 1
|
|
147
|
+
attempts: job.attempts + 1,
|
|
148
|
+
...job.cron ? {} : releaseKey(job)
|
|
104
149
|
}
|
|
105
150
|
});
|
|
106
151
|
if (count) {
|
|
107
152
|
const data = await this.accessor.findFirst({ where: { id: job.id } });
|
|
108
|
-
|
|
109
|
-
|
|
153
|
+
if (data) {
|
|
154
|
+
return rowToJob(data);
|
|
155
|
+
}
|
|
110
156
|
}
|
|
111
157
|
}
|
|
112
158
|
return void 0;
|
|
113
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* The `where` clause that matches the job's row only while it is still
|
|
162
|
+
* locked by the claim that `find()` returned as `job`.
|
|
163
|
+
*
|
|
164
|
+
* `lockedAt` identifies the claim: every claim sets it to the time of
|
|
165
|
+
* locking, and `find()` returns the value read back from the database, so
|
|
166
|
+
* it compares equal to the stored value regardless of the column's
|
|
167
|
+
* precision. Nothing else changes `lockedAt` while the job is locked, and
|
|
168
|
+
* a newer claim of the same job sets its own, different `lockedAt`. `attempts` alone
|
|
169
|
+
* is not unique per claim, because a successful run of a recurring job
|
|
170
|
+
* resets it to 0, and `lockedBy` is not unique either, because it names
|
|
171
|
+
* the worker process, which claims many jobs and can claim the same job
|
|
172
|
+
* again
|
|
173
|
+
*/
|
|
174
|
+
#claimedBy(job) {
|
|
175
|
+
return {
|
|
176
|
+
id: job.id,
|
|
177
|
+
failedAt: null,
|
|
178
|
+
attempts: job.attempts,
|
|
179
|
+
lockedAt: job.lockedAt
|
|
180
|
+
};
|
|
181
|
+
}
|
|
114
182
|
// Prisma queries are lazily evaluated and only sent to the db when they are
|
|
115
183
|
// awaited, so do the await here to ensure they actually run (if the user
|
|
116
184
|
// doesn't await the Promise then the queries will never be executed!)
|
|
117
185
|
//
|
|
118
186
|
// All of these updates/deletes are done with updateMany/deleteMany guarded
|
|
119
|
-
// on `failedAt: null` and on
|
|
120
|
-
// so the in-flight attempt's outcome can't
|
|
121
|
-
// when:
|
|
187
|
+
// on `failedAt: null` and on the job still being locked by this attempt's
|
|
188
|
+
// claim (see `#claimedBy()`), so the in-flight attempt's outcome can't
|
|
189
|
+
// overwrite or delete the row when:
|
|
122
190
|
// - the job was cancelled (or otherwise permanently failed) while this
|
|
123
191
|
// attempt was running (`failedAt` is no longer null), or
|
|
124
192
|
// - the job was reclaimed by another worker because this attempt stalled
|
|
125
|
-
// past `maxRuntime` plus the grace period
|
|
126
|
-
//
|
|
127
|
-
// `lockedBy` can't serve as that second fence because worker process names
|
|
128
|
-
// are deterministic and reused across restarts
|
|
193
|
+
// past `maxRuntime` plus the grace period, whether or not that newer
|
|
194
|
+
// claim has finished by the time this attempt's outcome is written
|
|
129
195
|
async success({ job, runAt, deleteJob }) {
|
|
130
196
|
this.logger.debug(`[CedarJS Jobs] Job ${job.id} success`);
|
|
131
197
|
if (deleteJob) {
|
|
132
198
|
await this.accessor.deleteMany({
|
|
133
|
-
where:
|
|
199
|
+
where: this.#claimedBy(job)
|
|
134
200
|
});
|
|
135
201
|
} else {
|
|
136
202
|
await this.accessor.updateMany({
|
|
137
|
-
where:
|
|
203
|
+
where: this.#claimedBy(job),
|
|
138
204
|
data: {
|
|
139
205
|
lockedAt: null,
|
|
140
206
|
lockedBy: null,
|
|
141
207
|
lastError: null,
|
|
142
|
-
runAt: runAt || null
|
|
208
|
+
runAt: runAt || null,
|
|
209
|
+
// A rescheduled (cron) job starts its next run with a clean slate,
|
|
210
|
+
// so `attempts` counts consecutive failures of a single run
|
|
211
|
+
...runAt ? { attempts: 0 } : {}
|
|
143
212
|
}
|
|
144
213
|
});
|
|
145
214
|
}
|
|
@@ -157,7 +226,7 @@ ${error.stack}`,
|
|
|
157
226
|
runAt
|
|
158
227
|
};
|
|
159
228
|
await this.accessor.updateMany({
|
|
160
|
-
where:
|
|
229
|
+
where: this.#claimedBy(job),
|
|
161
230
|
data
|
|
162
231
|
});
|
|
163
232
|
}
|
|
@@ -167,12 +236,13 @@ ${error.stack}`,
|
|
|
167
236
|
async failure({ job, deleteJob, error }) {
|
|
168
237
|
if (deleteJob) {
|
|
169
238
|
await this.accessor.deleteMany({
|
|
170
|
-
where:
|
|
239
|
+
where: this.#claimedBy(job)
|
|
171
240
|
});
|
|
172
241
|
} else {
|
|
173
242
|
const data = {
|
|
174
243
|
failedAt: /* @__PURE__ */ new Date(),
|
|
175
|
-
runAt: null
|
|
244
|
+
runAt: null,
|
|
245
|
+
...releaseKey(job)
|
|
176
246
|
};
|
|
177
247
|
if (error) {
|
|
178
248
|
data.lastError = `${error.message}
|
|
@@ -180,14 +250,21 @@ ${error.stack}`,
|
|
|
180
250
|
${error.stack}`;
|
|
181
251
|
}
|
|
182
252
|
await this.accessor.updateMany({
|
|
183
|
-
where:
|
|
253
|
+
where: this.#claimedBy(job),
|
|
184
254
|
data
|
|
185
255
|
});
|
|
186
256
|
}
|
|
187
257
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Schedules a job by creating a new record in the background job table.
|
|
260
|
+
* Returns the created job so that callers get access to its `id` (to cancel
|
|
261
|
+
* it later, poll its status, etc.)
|
|
262
|
+
*
|
|
263
|
+
* With a `key`, the `key` column's unique constraint makes sure only one
|
|
264
|
+
* queued job holds the key, also when several processes schedule at once.
|
|
265
|
+
* If the insert hits the constraint, the job that holds the key is
|
|
266
|
+
* returned with `alreadyQueued: true` instead
|
|
267
|
+
*/
|
|
191
268
|
async schedule({
|
|
192
269
|
name,
|
|
193
270
|
path,
|
|
@@ -195,19 +272,151 @@ ${error.stack}`;
|
|
|
195
272
|
runAt,
|
|
196
273
|
cron,
|
|
197
274
|
queue,
|
|
198
|
-
priority
|
|
275
|
+
priority,
|
|
276
|
+
key
|
|
199
277
|
}) {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
278
|
+
const job = { name, path, args };
|
|
279
|
+
const data = {
|
|
280
|
+
handler: JSON.stringify(job),
|
|
281
|
+
runAt,
|
|
282
|
+
cron,
|
|
283
|
+
queue,
|
|
284
|
+
priority,
|
|
285
|
+
...key !== void 0 ? { key } : {}
|
|
286
|
+
};
|
|
287
|
+
if (key === void 0) {
|
|
288
|
+
return { ...await this.#create(data, job), alreadyQueued: false };
|
|
289
|
+
}
|
|
290
|
+
for (let attempt = 0; attempt <= MAX_KEY_RETRIES; attempt++) {
|
|
291
|
+
try {
|
|
292
|
+
return { ...await this.#create(data, job), alreadyQueued: false };
|
|
293
|
+
} catch (e) {
|
|
294
|
+
if (!isUniqueViolation(e)) {
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
207
297
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
298
|
+
const queued = await this.accessor.findFirst({ where: { key } });
|
|
299
|
+
if (queued) {
|
|
300
|
+
return { ...rowToJob(queued), alreadyQueued: true };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
throw new CedarJSJobsError(
|
|
304
|
+
`Could not schedule ${name} with the key \`${key}\`: the key was taken and released again ${MAX_KEY_RETRIES + 1} times in a row`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
async #create(data, job) {
|
|
308
|
+
let row;
|
|
309
|
+
try {
|
|
310
|
+
row = await this.accessor.create({ data });
|
|
311
|
+
} catch (e) {
|
|
312
|
+
if (isMissingKeyColumn(e)) {
|
|
313
|
+
throw new KeyColumnMissingError(String(this.model));
|
|
314
|
+
}
|
|
315
|
+
throw e;
|
|
316
|
+
}
|
|
317
|
+
return { ...row, ...job };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Makes the stored declared schedules match `schedules`. Each schedule is
|
|
321
|
+
* one row, identified by its unique `key`, so concurrent syncs can't create
|
|
322
|
+
* duplicates: an insert that hits the unique constraint compares against
|
|
323
|
+
* (and if needed updates) the row the other sync created instead
|
|
324
|
+
*/
|
|
325
|
+
async syncSchedules({
|
|
326
|
+
schedules,
|
|
327
|
+
keyPrefix
|
|
328
|
+
}) {
|
|
329
|
+
const result = {
|
|
330
|
+
created: [],
|
|
331
|
+
updated: [],
|
|
332
|
+
removed: [],
|
|
333
|
+
unchanged: []
|
|
334
|
+
};
|
|
335
|
+
if (!this.accessor.findMany) {
|
|
336
|
+
throw new ModelNameError(String(this.model));
|
|
337
|
+
}
|
|
338
|
+
let stored;
|
|
339
|
+
try {
|
|
340
|
+
stored = await this.accessor.findMany({
|
|
341
|
+
where: { key: { startsWith: keyPrefix } }
|
|
342
|
+
});
|
|
343
|
+
} catch (e) {
|
|
344
|
+
if (!isMissingKeyColumn(e)) {
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
if (schedules.length === 0) {
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
throw new KeyColumnMissingError(String(this.model));
|
|
351
|
+
}
|
|
352
|
+
const storedByKey = new Map(stored.map((row) => [row.key, row]));
|
|
353
|
+
for (const schedule of schedules) {
|
|
354
|
+
const outcome = await this.#syncSchedule(
|
|
355
|
+
schedule,
|
|
356
|
+
storedByKey.get(schedule.key)
|
|
357
|
+
);
|
|
358
|
+
result[outcome].push(schedule.key);
|
|
359
|
+
}
|
|
360
|
+
const declared = new Set(schedules.map((schedule) => schedule.key));
|
|
361
|
+
const undeclared = stored.map((row) => row.key).filter((key) => !!key && !declared.has(key));
|
|
362
|
+
if (undeclared.length > 0) {
|
|
363
|
+
await this.accessor.deleteMany({ where: { key: { in: undeclared } } });
|
|
364
|
+
result.removed.push(...undeclared);
|
|
365
|
+
}
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
async #syncSchedule(schedule, stored) {
|
|
369
|
+
const { name, path, args, cron, runAt, queue, priority, key } = schedule;
|
|
370
|
+
const handler = JSON.stringify({ name, path, args });
|
|
371
|
+
let row = stored;
|
|
372
|
+
for (let attempt = 0; attempt <= MAX_KEY_RETRIES; attempt++) {
|
|
373
|
+
if (!row) {
|
|
374
|
+
try {
|
|
375
|
+
await this.accessor.create({
|
|
376
|
+
data: { handler, runAt, cron, queue, priority, key }
|
|
377
|
+
});
|
|
378
|
+
return "created";
|
|
379
|
+
} catch (e) {
|
|
380
|
+
if (!isUniqueViolation(e)) {
|
|
381
|
+
throw e;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
row = await this.accessor.findFirst({ where: { key } });
|
|
385
|
+
if (!row) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const changes = {};
|
|
390
|
+
if (row.handler !== handler) {
|
|
391
|
+
changes.handler = handler;
|
|
392
|
+
}
|
|
393
|
+
if (row.queue !== queue) {
|
|
394
|
+
changes.queue = queue;
|
|
395
|
+
}
|
|
396
|
+
if (row.priority !== priority) {
|
|
397
|
+
changes.priority = priority;
|
|
398
|
+
}
|
|
399
|
+
if (row.cron !== cron) {
|
|
400
|
+
changes.cron = cron;
|
|
401
|
+
if (!row.lockedAt) {
|
|
402
|
+
changes.runAt = runAt;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (Object.keys(changes).length === 0) {
|
|
406
|
+
return "unchanged";
|
|
407
|
+
}
|
|
408
|
+
const { count } = await this.accessor.updateMany({
|
|
409
|
+
where: { id: row.id },
|
|
410
|
+
data: changes
|
|
411
|
+
});
|
|
412
|
+
if (count > 0) {
|
|
413
|
+
return "updated";
|
|
414
|
+
}
|
|
415
|
+
row = void 0;
|
|
416
|
+
}
|
|
417
|
+
throw new CedarJSJobsError(
|
|
418
|
+
`Could not sync the schedule \`${key}\`: it was created and removed again ${MAX_KEY_RETRIES + 1} times in a row`
|
|
419
|
+
);
|
|
211
420
|
}
|
|
212
421
|
/**
|
|
213
422
|
* Cancels a job by marking it as permanently failed so no worker will pick
|
|
@@ -220,16 +429,148 @@ ${error.stack}`;
|
|
|
220
429
|
*/
|
|
221
430
|
async cancel({ jobId }) {
|
|
222
431
|
this.logger.debug(`[CedarJS Jobs] Cancelling job ${jobId}`);
|
|
432
|
+
const job = await this.accessor.findFirst({
|
|
433
|
+
where: { id: jobId }
|
|
434
|
+
});
|
|
223
435
|
const { count } = await this.accessor.updateMany({
|
|
224
436
|
where: { id: jobId, failedAt: null, runAt: { not: null } },
|
|
225
437
|
data: {
|
|
226
438
|
failedAt: /* @__PURE__ */ new Date(),
|
|
227
439
|
lastError: "Job cancelled by user",
|
|
228
|
-
runAt: null
|
|
440
|
+
runAt: null,
|
|
441
|
+
...releaseKey(job ?? {})
|
|
229
442
|
}
|
|
230
443
|
});
|
|
231
444
|
return count > 0;
|
|
232
445
|
}
|
|
446
|
+
/**
|
|
447
|
+
* Pauses a job by setting its `pausedAt`, which `find()` leaves out. A run
|
|
448
|
+
* in progress finishes, and `success()` and `error()` keep `pausedAt` when
|
|
449
|
+
* they reschedule the job
|
|
450
|
+
*/
|
|
451
|
+
async pause({ jobId }) {
|
|
452
|
+
const row = await this.#findActive(jobId);
|
|
453
|
+
if (row.pausedAt) {
|
|
454
|
+
return { alreadyPaused: true, pausedAt: row.pausedAt };
|
|
455
|
+
}
|
|
456
|
+
const pausedAt = /* @__PURE__ */ new Date();
|
|
457
|
+
const { count } = await this.accessor.updateMany({
|
|
458
|
+
where: {
|
|
459
|
+
id: row.id,
|
|
460
|
+
pausedAt: null,
|
|
461
|
+
failedAt: null,
|
|
462
|
+
runAt: { not: null }
|
|
463
|
+
},
|
|
464
|
+
data: { pausedAt }
|
|
465
|
+
});
|
|
466
|
+
if (count === 0) {
|
|
467
|
+
const current = await this.#findActive(jobId);
|
|
468
|
+
if (!current.pausedAt) {
|
|
469
|
+
return this.pause({ jobId });
|
|
470
|
+
}
|
|
471
|
+
return { alreadyPaused: true, pausedAt: current.pausedAt };
|
|
472
|
+
}
|
|
473
|
+
return { alreadyPaused: false, pausedAt };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Resumes a paused job. A recurring job runs next at the next match of its
|
|
477
|
+
* cron from now, and, unless it's running, starts that run with a clean
|
|
478
|
+
* slate (`attempts` reset to 0, like after a successful run). A running
|
|
479
|
+
* job keeps its `attempts` and `runAt`: `attempts` identifies the run's
|
|
480
|
+
* claim (see `#claimedBy()`), and the worker sets the next run time when
|
|
481
|
+
* the run finishes, so a run that finishes while this write is under way
|
|
482
|
+
* keeps the run time it set, and the result's `runAt` is `null`. A one-off
|
|
483
|
+
* job keeps its `runAt`
|
|
484
|
+
*/
|
|
485
|
+
async resume({ jobId }) {
|
|
486
|
+
const row = await this.#findActive(jobId);
|
|
487
|
+
if (!row.pausedAt) {
|
|
488
|
+
return { wasPaused: false, runAt: row.runAt };
|
|
489
|
+
}
|
|
490
|
+
const running = !!row.lockedAt;
|
|
491
|
+
const runAt = !row.cron ? row.runAt : running ? null : nextCronRunAt(row.cron);
|
|
492
|
+
const { count } = await this.accessor.updateMany({
|
|
493
|
+
where: {
|
|
494
|
+
id: row.id,
|
|
495
|
+
pausedAt: { not: null },
|
|
496
|
+
failedAt: null,
|
|
497
|
+
runAt: { not: null }
|
|
498
|
+
},
|
|
499
|
+
data: {
|
|
500
|
+
pausedAt: null,
|
|
501
|
+
...row.cron && !running ? { runAt, attempts: 0 } : {}
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
if (count === 0) {
|
|
505
|
+
const current = await this.#findActive(jobId);
|
|
506
|
+
return { wasPaused: false, runAt: current.runAt };
|
|
507
|
+
}
|
|
508
|
+
return { wasPaused: true, runAt };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Reads a job that can be paused or resumed: one that hasn't completed or
|
|
512
|
+
* failed for good. The whole row is read, so `pausedAt` is on it exactly
|
|
513
|
+
* when the model has the column
|
|
514
|
+
*/
|
|
515
|
+
async #findActive(jobId) {
|
|
516
|
+
const row = await this.accessor.findFirst({
|
|
517
|
+
where: { id: jobId }
|
|
518
|
+
});
|
|
519
|
+
if (!row) {
|
|
520
|
+
throw new JobIdNotFoundError(jobId);
|
|
521
|
+
}
|
|
522
|
+
if (!("pausedAt" in row)) {
|
|
523
|
+
throw new PausedAtColumnMissingError(String(this.model));
|
|
524
|
+
}
|
|
525
|
+
if (row.failedAt) {
|
|
526
|
+
throw new JobNotActiveError(jobId, "failed");
|
|
527
|
+
}
|
|
528
|
+
if (!row.runAt) {
|
|
529
|
+
throw new JobNotActiveError(jobId, "completed");
|
|
530
|
+
}
|
|
531
|
+
return row;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Lists the jobs that haven't completed or failed for good, the ones that
|
|
535
|
+
* run next first
|
|
536
|
+
*/
|
|
537
|
+
async list({
|
|
538
|
+
limit,
|
|
539
|
+
queues,
|
|
540
|
+
paused
|
|
541
|
+
}) {
|
|
542
|
+
if (!this.accessor.findMany) {
|
|
543
|
+
throw new ModelNameError(String(this.model));
|
|
544
|
+
}
|
|
545
|
+
let rows;
|
|
546
|
+
try {
|
|
547
|
+
rows = await this.accessor.findMany({
|
|
548
|
+
where: {
|
|
549
|
+
failedAt: null,
|
|
550
|
+
runAt: { not: null },
|
|
551
|
+
...queues && !(queues.length === 1 && queues[0] === "*") ? { queue: { in: queues } } : {},
|
|
552
|
+
...paused ? { pausedAt: { not: null } } : {}
|
|
553
|
+
},
|
|
554
|
+
orderBy: [{ runAt: "asc" }, { id: "asc" }],
|
|
555
|
+
take: limit
|
|
556
|
+
});
|
|
557
|
+
} catch (e) {
|
|
558
|
+
if (paused && isMissingPausedAtColumn(e)) {
|
|
559
|
+
return [];
|
|
560
|
+
}
|
|
561
|
+
throw e;
|
|
562
|
+
}
|
|
563
|
+
return rows.map((row) => ({
|
|
564
|
+
id: row.id,
|
|
565
|
+
name: JSON.parse(row.handler).name,
|
|
566
|
+
queue: row.queue,
|
|
567
|
+
cron: row.cron ?? null,
|
|
568
|
+
runAt: row.runAt,
|
|
569
|
+
pausedAt: row.pausedAt ?? null,
|
|
570
|
+
running: !!row.lockedAt,
|
|
571
|
+
attempts: row.attempts
|
|
572
|
+
}));
|
|
573
|
+
}
|
|
233
574
|
async clear() {
|
|
234
575
|
await this.accessor.deleteMany();
|
|
235
576
|
}
|
|
@@ -5,4 +5,18 @@ import { CedarJSJobsError } from '../../errors.js';
|
|
|
5
5
|
export declare class ModelNameError extends CedarJSJobsError {
|
|
6
6
|
constructor(name: string);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Thrown when a job is scheduled with a unique `key`, or jobs declare
|
|
10
|
+
* `schedules`, but the jobs model has no `key` column to store the key in
|
|
11
|
+
*/
|
|
12
|
+
export declare class KeyColumnMissingError extends CedarJSJobsError {
|
|
13
|
+
constructor(model: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when a job is paused or resumed, but the jobs model has no
|
|
17
|
+
* `pausedAt` column to store the pause in
|
|
18
|
+
*/
|
|
19
|
+
export declare class PausedAtColumnMissingError extends CedarJSJobsError {
|
|
20
|
+
constructor(model: string);
|
|
21
|
+
}
|
|
8
22
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD;;GAEG;AACH,qBAAa,cAAe,SAAQ,gBAAgB;gBACtC,IAAI,EAAE,MAAM;CAGzB"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/adapters/PrismaAdapter/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD;;GAEG;AACH,qBAAa,cAAe,SAAQ,gBAAgB;gBACtC,IAAI,EAAE,MAAM;CAGzB;AAED;;;GAGG;AACH,qBAAa,qBAAsB,SAAQ,gBAAgB;gBAC7C,KAAK,EAAE,MAAM;CAQ1B;AAED;;;GAGG;AACH,qBAAa,0BAA2B,SAAQ,gBAAgB;gBAClD,KAAK,EAAE,MAAM;CAO1B"}
|
|
@@ -4,6 +4,22 @@ class ModelNameError extends CedarJSJobsError {
|
|
|
4
4
|
super(`Model \`${name}\` not found in PrismaClient`);
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
class KeyColumnMissingError extends CedarJSJobsError {
|
|
8
|
+
constructor(model) {
|
|
9
|
+
super(
|
|
10
|
+
`The \`${model}\` model has no \`key\` column, which unique job keys and declared schedules need. Add \`key String? @unique\` to the model in your Prisma schema and create a migration with \`yarn cedar prisma migrate dev\``
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class PausedAtColumnMissingError extends CedarJSJobsError {
|
|
15
|
+
constructor(model) {
|
|
16
|
+
super(
|
|
17
|
+
`The \`${model}\` model has no \`pausedAt\` column, which pausing jobs needs. Add \`pausedAt DateTime?\` to the model in your Prisma schema and create a migration with \`yarn cedar prisma migrate dev\``
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
export {
|
|
8
|
-
|
|
22
|
+
KeyColumnMissingError,
|
|
23
|
+
ModelNameError,
|
|
24
|
+
PausedAtColumnMissingError
|
|
9
25
|
};
|
|
@@ -4,6 +4,17 @@ export declare const processName: ({ id, queues, }: {
|
|
|
4
4
|
id: number;
|
|
5
5
|
queues: string | string[];
|
|
6
6
|
}) => string;
|
|
7
|
+
/**
|
|
8
|
+
* The name a worker locks jobs with (`lockedBy`). A worker takes back any job
|
|
9
|
+
* locked under its own name, so the name has to be unique to this process:
|
|
10
|
+
* the process name alone is the same for the same worker config in every
|
|
11
|
+
* app server, on every host, and two workers sharing it would both run a job,
|
|
12
|
+
* and each one's result would hand the job back to the other to run again
|
|
13
|
+
*/
|
|
14
|
+
export declare const lockName: ({ id, queues, }: {
|
|
15
|
+
id: number;
|
|
16
|
+
queues: string | string[];
|
|
17
|
+
}) => string;
|
|
7
18
|
export declare const getWorker: ({ index, id, clear, workoff, }: {
|
|
8
19
|
index: number;
|
|
9
20
|
id: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cedar-jobs-worker.d.ts","sourceRoot":"","sources":["../../src/bins/cedar-jobs-worker.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"cedar-jobs-worker.d.ts","sourceRoot":"","sources":["../../src/bins/cedar-jobs-worker.ts"],"names":[],"mappings":";AAYA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAA;AAwC/C,eAAO,MAAM,WAAW,GAAI,iBAGzB;IACD,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAC1B,WAEA,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,GAAI,iBAGtB;IACD,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;CAC1B,WAEA,CAAA;AA0BD,eAAO,MAAM,SAAS,GAAU,gCAK7B;IACD,KAAK,EAAE,MAAM,CAAA;IACb,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,OAAO,CAAA;IACd,OAAO,EAAE,OAAO,CAAA;CACjB,oBA+BA,CAAA"}
|