effect-mq 0.1.0 → 0.3.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/README.md +305 -25
- package/dist/Job.d.ts +118 -5
- package/dist/Job.d.ts.map +1 -1
- package/dist/Job.js +119 -4
- package/dist/Job.js.map +1 -1
- package/dist/JobStore.d.ts +260 -9
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +115 -1
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts +38 -6
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +351 -47
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/Worker.d.ts +5 -1
- package/dist/Worker.d.ts.map +1 -1
- package/dist/Worker.js +117 -10
- package/dist/Worker.js.map +1 -1
- package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
- package/dist/drizzle-postgres/index.d.ts.map +1 -0
- package/dist/drizzle-postgres/index.js.map +1 -0
- package/dist/drizzle-postgres/schema.d.ts +670 -0
- package/dist/drizzle-postgres/schema.d.ts.map +1 -0
- package/dist/drizzle-postgres/schema.js +150 -0
- package/dist/drizzle-postgres/schema.js.map +1 -0
- package/dist/redis/RedisJobStore.d.ts +58 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -0
- package/dist/redis/RedisJobStore.js +424 -0
- package/dist/redis/RedisJobStore.js.map +1 -0
- package/dist/redis/index.d.ts +9 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +9 -0
- package/dist/redis/index.js.map +1 -0
- package/dist/redis/scripts.d.ts +181 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/scripts.js +940 -0
- package/dist/redis/scripts.js.map +1 -0
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +502 -8
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +8 -4
- package/src/Job.ts +301 -10
- package/src/JobStore.ts +373 -9
- package/src/MemoryJobStore.ts +440 -53
- package/src/Worker.ts +153 -10
- package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
- package/src/drizzle-postgres/schema.ts +279 -0
- package/src/redis/RedisJobStore.ts +652 -0
- package/src/redis/index.ts +8 -0
- package/src/redis/scripts.ts +1055 -0
- package/src/testing/conformance.ts +665 -8
- package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
- package/dist/drizzle/DrizzleJobStore.js +0 -426
- package/dist/drizzle/DrizzleJobStore.js.map +0 -1
- package/dist/drizzle/index.d.ts.map +0 -1
- package/dist/drizzle/index.js.map +0 -1
- package/dist/drizzle/schema.d.ts +0 -464
- package/dist/drizzle/schema.d.ts.map +0 -1
- package/dist/drizzle/schema.js +0 -68
- package/dist/drizzle/schema.js.map +0 -1
- package/src/drizzle/DrizzleJobStore.ts +0 -599
- package/src/drizzle/schema.ts +0 -116
- /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
- /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
- /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
package/dist/MemoryJobStore.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @since 0.1.0
|
|
12
12
|
*/
|
|
13
|
-
import { Clock, Deferred, Effect, Exit, Layer, Option } from "effect";
|
|
14
|
-
import { JobId, JobNotFoundError, JobNotRetryableError, JobStore, LockLostError } from "./JobStore.js";
|
|
13
|
+
import { Clock, Deferred, Duration, Effect, Exit, Layer, Option } from "effect";
|
|
14
|
+
import { JobId, JobNotCancellableError, JobNotFoundError, JobNotPromotableError, JobNotRetryableError, JobStore, LockLostError, JobStoreError, normalizeHistoryTtl } from "./JobStore.js";
|
|
15
|
+
const TERMINAL_STATES = new Set(["completed", "failed", "cancelled"]);
|
|
15
16
|
const snapshot = (job) => ({
|
|
16
17
|
id: job.id,
|
|
17
18
|
name: job.name,
|
|
@@ -25,6 +26,9 @@ const snapshot = (job) => ({
|
|
|
25
26
|
stalledCount: job.stalledCount,
|
|
26
27
|
backoff: job.backoff,
|
|
27
28
|
keep: job.keep,
|
|
29
|
+
timeoutMs: job.timeoutMs,
|
|
30
|
+
cancelRequested: job.cancelRequested,
|
|
31
|
+
dedupeKey: job.dedupeKey,
|
|
28
32
|
runAt: job.runAt,
|
|
29
33
|
enqueuedAt: job.enqueuedAt,
|
|
30
34
|
processedAt: job.processedAt,
|
|
@@ -39,25 +43,46 @@ const metadataMatches = (record, filter) => {
|
|
|
39
43
|
}
|
|
40
44
|
return true;
|
|
41
45
|
};
|
|
42
|
-
|
|
43
|
-
* Build a fresh in-memory `JobStore` implementation.
|
|
44
|
-
*
|
|
45
|
-
* @since 0.1.0
|
|
46
|
-
*/
|
|
47
|
-
export const make = Effect.sync(() => {
|
|
46
|
+
const makeStoreUnsafe = (options) => {
|
|
48
47
|
const jobs = new Map();
|
|
48
|
+
const schedules = new Map();
|
|
49
|
+
const paused = new Set();
|
|
50
|
+
// Dedup registry: one entry per (name, key). `expiresAt` is set for
|
|
51
|
+
// ttl/throttle windows; pending-mode entries live as long as their job.
|
|
52
|
+
const dedupes = new Map();
|
|
53
|
+
const dedupeMapKey = (name, key) => `${name}\u0000${key}`;
|
|
49
54
|
let seq = 0;
|
|
50
55
|
let idCounter = 0;
|
|
51
56
|
let wakeVersion = 0;
|
|
52
|
-
let
|
|
57
|
+
let lastBroadcast = 0;
|
|
58
|
+
const lastWake = new Map();
|
|
59
|
+
const waiters = new Set();
|
|
60
|
+
const lastWakeFor = (queue) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast);
|
|
53
61
|
// Synchronous on purpose: it is called inside the same synchronous block as
|
|
54
62
|
// the state mutation, so no effect-op boundary (where an interrupt could
|
|
55
|
-
// land) can separate a mutation from its wake-up signal.
|
|
56
|
-
|
|
63
|
+
// land) can separate a mutation from its wake-up signal. A queue targets
|
|
64
|
+
// only waiters watching it; no queue broadcasts (rare maintenance verbs).
|
|
65
|
+
const signalWake = (queue) => {
|
|
57
66
|
wakeVersion += 1;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
if (queue === undefined) {
|
|
68
|
+
lastBroadcast = wakeVersion;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lastWake.set(queue, wakeVersion);
|
|
72
|
+
}
|
|
73
|
+
// Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting fibers
|
|
74
|
+
// synchronously, and a woken taker that re-parks registers a NEW waiter —
|
|
75
|
+
// resolving inside the live Set iteration would visit it and livelock.
|
|
76
|
+
const toWake = [];
|
|
77
|
+
for (const waiter of waiters) {
|
|
78
|
+
if (queue === undefined || waiter.queues.has(queue)) {
|
|
79
|
+
waiters.delete(waiter);
|
|
80
|
+
toWake.push(waiter);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const waiter of toWake) {
|
|
84
|
+
Deferred.doneUnsafe(waiter.deferred, Exit.succeed(void 0));
|
|
85
|
+
}
|
|
61
86
|
};
|
|
62
87
|
const promoteDue = (now) => {
|
|
63
88
|
for (const job of jobs.values()) {
|
|
@@ -79,24 +104,68 @@ export const make = Effect.sync(() => {
|
|
|
79
104
|
exit
|
|
80
105
|
});
|
|
81
106
|
};
|
|
107
|
+
// A job leaving the pending states frees its pending-mode dedup entry;
|
|
108
|
+
// live throttle windows deliberately outlast the job.
|
|
109
|
+
const releaseDedupe = (job, now) => {
|
|
110
|
+
if (job.dedupeKey === undefined)
|
|
111
|
+
return;
|
|
112
|
+
const key = dedupeMapKey(job.name, job.dedupeKey);
|
|
113
|
+
const entry = dedupes.get(key);
|
|
114
|
+
if (entry !== undefined && entry.jobId === job.id &&
|
|
115
|
+
(entry.expiresAt === undefined || entry.expiresAt <= now)) {
|
|
116
|
+
dedupes.delete(key);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const markCancelled = (job, now) => {
|
|
120
|
+
clearLock(job);
|
|
121
|
+
job.cancelRequested = false;
|
|
122
|
+
job.state = "cancelled";
|
|
123
|
+
job.finishedAt = now;
|
|
124
|
+
recordAttempt(job, "cancelled", now, undefined);
|
|
125
|
+
releaseDedupe(job, now);
|
|
126
|
+
applyKeep(job, now);
|
|
127
|
+
};
|
|
128
|
+
const keepPolicyFor = (keep, state) => {
|
|
129
|
+
if (keep === undefined)
|
|
130
|
+
return undefined;
|
|
131
|
+
const policy = state === "completed"
|
|
132
|
+
? keep.completed
|
|
133
|
+
: state === "failed"
|
|
134
|
+
? keep.failed
|
|
135
|
+
: state === "cancelled"
|
|
136
|
+
? keep.cancelled
|
|
137
|
+
: undefined;
|
|
138
|
+
if (policy !== undefined)
|
|
139
|
+
return policy;
|
|
140
|
+
// Records persisted by 0.2.x carry the flat {count, ageMs} shape — honour
|
|
141
|
+
// it as an all-states policy so upgrades keep pruning.
|
|
142
|
+
if (keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
|
|
143
|
+
("count" in keep || "ageMs" in keep)) {
|
|
144
|
+
// SAFETY: the flat legacy shape carries KeepStatePolicy fields.
|
|
145
|
+
return keep;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
82
149
|
// Terminal retention: keep at most `count` and drop older than `ageMs`
|
|
83
|
-
// among terminal jobs sharing this job's name + state
|
|
150
|
+
// among terminal jobs sharing this job's name + state (policies are split
|
|
151
|
+
// per terminal state).
|
|
84
152
|
const applyKeep = (job, now) => {
|
|
85
|
-
|
|
153
|
+
const policy = keepPolicyFor(job.keep, job.state);
|
|
154
|
+
if (policy === undefined)
|
|
86
155
|
return;
|
|
87
156
|
const peers = Array.from(jobs.values())
|
|
88
157
|
.filter((peer) => peer.name === job.name && peer.state === job.state)
|
|
89
158
|
.toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq));
|
|
90
159
|
const remove = new Set();
|
|
91
|
-
if (
|
|
160
|
+
if (policy.ageMs !== undefined) {
|
|
92
161
|
for (const peer of peers) {
|
|
93
|
-
if (peer.finishedAt !== undefined && peer.finishedAt <= now -
|
|
162
|
+
if (peer.finishedAt !== undefined && peer.finishedAt <= now - policy.ageMs) {
|
|
94
163
|
remove.add(peer.id);
|
|
95
164
|
}
|
|
96
165
|
}
|
|
97
166
|
}
|
|
98
|
-
if (
|
|
99
|
-
for (const peer of peers.slice(Math.max(0,
|
|
167
|
+
if (policy.count !== undefined) {
|
|
168
|
+
for (const peer of peers.slice(Math.max(0, policy.count))) {
|
|
100
169
|
remove.add(peer.id);
|
|
101
170
|
}
|
|
102
171
|
}
|
|
@@ -104,18 +173,100 @@ export const make = Effect.sync(() => {
|
|
|
104
173
|
jobs.delete(id);
|
|
105
174
|
}
|
|
106
175
|
};
|
|
107
|
-
|
|
176
|
+
const sweepHistory = (now, ttlByState) => {
|
|
177
|
+
for (const job of jobs.values()) {
|
|
178
|
+
if (!TERMINAL_STATES.has(job.state) || job.finishedAt === undefined)
|
|
179
|
+
continue;
|
|
180
|
+
// SAFETY: TERMINAL_STATES membership was checked above.
|
|
181
|
+
const state = job.state;
|
|
182
|
+
const ttl = ttlByState[state];
|
|
183
|
+
const keepAge = keepPolicyFor(job.keep, state)?.ageMs;
|
|
184
|
+
// The sweep honours min(per-row keep age, store ceiling) — a quiet job
|
|
185
|
+
// name is pruned on the timer, not only when its group is acked.
|
|
186
|
+
const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl;
|
|
187
|
+
if (effective !== undefined && job.finishedAt <= now - effective) {
|
|
188
|
+
jobs.delete(job.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Dead dedup entries: expired window, or a pointer at a vanished job.
|
|
192
|
+
for (const [key, entry] of dedupes) {
|
|
193
|
+
const alive = entry.expiresAt !== undefined
|
|
194
|
+
? entry.expiresAt > now
|
|
195
|
+
: jobs.has(entry.jobId) && !TERMINAL_STATES.has(jobs.get(entry.jobId)?.state ?? "completed");
|
|
196
|
+
if (!alive)
|
|
197
|
+
dedupes.delete(key);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const service = JobStore.of({
|
|
108
201
|
enqueue: (request) => Effect.gen(function* () {
|
|
109
202
|
const now = yield* Clock.currentTimeMillis;
|
|
203
|
+
if (request.id !== undefined && jobs.has(request.id)) {
|
|
204
|
+
return { id: request.id, duplicate: true };
|
|
205
|
+
}
|
|
206
|
+
// The dedup decision tree runs BEFORE id generation, so a
|
|
207
|
+
// deduplicated enqueue never consults the id generator.
|
|
208
|
+
if (request.dedupe !== undefined) {
|
|
209
|
+
const mapKey = dedupeMapKey(request.name, request.dedupe.key);
|
|
210
|
+
const entry = dedupes.get(mapKey);
|
|
211
|
+
if (entry !== undefined) {
|
|
212
|
+
const keyed = jobs.get(entry.jobId);
|
|
213
|
+
const windowLive = entry.expiresAt !== undefined && now < entry.expiresAt;
|
|
214
|
+
// Latest-wins while the keyed job is still delayed.
|
|
215
|
+
if (request.dedupe.replace && keyed !== undefined && keyed.state === "delayed") {
|
|
216
|
+
keyed.payload = request.payload;
|
|
217
|
+
keyed.metadata = request.metadata;
|
|
218
|
+
keyed.priority = request.priority;
|
|
219
|
+
keyed.attemptsMax = request.attemptsMax;
|
|
220
|
+
keyed.backoff = request.backoff;
|
|
221
|
+
keyed.keep = request.keep;
|
|
222
|
+
keyed.timeoutMs = request.timeoutMs;
|
|
223
|
+
keyed.runAt = now + Math.max(0, request.delayMs);
|
|
224
|
+
// A landed replace re-arms the ttl window.
|
|
225
|
+
if (request.dedupe.ttlMs !== undefined) {
|
|
226
|
+
entry.expiresAt = now + request.dedupe.ttlMs;
|
|
227
|
+
}
|
|
228
|
+
signalWake(keyed.queue);
|
|
229
|
+
return { id: keyed.id, duplicate: true };
|
|
230
|
+
}
|
|
231
|
+
if (windowLive) {
|
|
232
|
+
if (request.dedupe.extend && request.dedupe.ttlMs !== undefined) {
|
|
233
|
+
entry.expiresAt = now + request.dedupe.ttlMs;
|
|
234
|
+
}
|
|
235
|
+
return { id: entry.jobId, duplicate: true };
|
|
236
|
+
}
|
|
237
|
+
const pending = keyed !== undefined && !TERMINAL_STATES.has(keyed.state);
|
|
238
|
+
if (entry.expiresAt === undefined && pending) {
|
|
239
|
+
return { id: entry.jobId, duplicate: true };
|
|
240
|
+
}
|
|
241
|
+
// Dead entry (expired window / finished job): fall through and
|
|
242
|
+
// let the new job take over the key below.
|
|
243
|
+
}
|
|
244
|
+
}
|
|
110
245
|
let id = request.id;
|
|
111
246
|
if (id === undefined) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
247
|
+
const generate = options?.idGenerator;
|
|
248
|
+
if (generate === undefined) {
|
|
249
|
+
// Store-assigned ids must never collide with user-supplied ones.
|
|
250
|
+
do {
|
|
251
|
+
id = JobId(`j-${++idCounter}`);
|
|
252
|
+
} while (jobs.has(id));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
// A user generator gets a bounded number of collision retries; a
|
|
256
|
+
// healthy generator's entropy makes even one retry pathological.
|
|
257
|
+
for (let i = 0; i < 5 && id === undefined; i++) {
|
|
258
|
+
const raw = generate(request);
|
|
259
|
+
const candidate = JobId(Effect.isEffect(raw) ? yield* raw : raw);
|
|
260
|
+
if (!jobs.has(candidate)) {
|
|
261
|
+
id = candidate;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (id === undefined) {
|
|
265
|
+
return yield* new JobStoreError({
|
|
266
|
+
message: "enqueue failed: could not generate a unique job id"
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
119
270
|
}
|
|
120
271
|
jobs.set(id, {
|
|
121
272
|
id,
|
|
@@ -130,6 +281,9 @@ export const make = Effect.sync(() => {
|
|
|
130
281
|
stalledCount: 0,
|
|
131
282
|
backoff: request.backoff,
|
|
132
283
|
keep: request.keep,
|
|
284
|
+
timeoutMs: request.timeoutMs,
|
|
285
|
+
cancelRequested: false,
|
|
286
|
+
dedupeKey: request.dedupe?.key,
|
|
133
287
|
runAt: now + Math.max(0, request.delayMs),
|
|
134
288
|
enqueuedAt: now,
|
|
135
289
|
processedAt: undefined,
|
|
@@ -141,7 +295,13 @@ export const make = Effect.sync(() => {
|
|
|
141
295
|
lockToken: undefined,
|
|
142
296
|
lockExpiresAt: undefined
|
|
143
297
|
});
|
|
144
|
-
|
|
298
|
+
if (request.dedupe !== undefined) {
|
|
299
|
+
dedupes.set(dedupeMapKey(request.name, request.dedupe.key), {
|
|
300
|
+
jobId: id,
|
|
301
|
+
expiresAt: request.dedupe.ttlMs !== undefined ? now + request.dedupe.ttlMs : undefined
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
signalWake(request.queue);
|
|
145
305
|
return { id, duplicate: false };
|
|
146
306
|
}),
|
|
147
307
|
claim: (options) => Effect.gen(function* () {
|
|
@@ -166,7 +326,7 @@ export const make = Effect.sync(() => {
|
|
|
166
326
|
}
|
|
167
327
|
}
|
|
168
328
|
}
|
|
169
|
-
if (best === undefined) {
|
|
329
|
+
if (best === undefined || paused.has(options.queue)) {
|
|
170
330
|
const empty = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion };
|
|
171
331
|
return empty;
|
|
172
332
|
}
|
|
@@ -190,32 +350,53 @@ export const make = Effect.sync(() => {
|
|
|
190
350
|
job.attemptsMade += 1;
|
|
191
351
|
switch (outcome._tag) {
|
|
192
352
|
case "Complete": {
|
|
353
|
+
job.cancelRequested = false;
|
|
193
354
|
job.state = "completed";
|
|
194
355
|
job.exit = outcome.exit;
|
|
195
356
|
job.finishedAt = now;
|
|
196
357
|
recordAttempt(job, "completed", now, outcome.exit);
|
|
358
|
+
releaseDedupe(job, now);
|
|
197
359
|
applyKeep(job, now);
|
|
198
360
|
break;
|
|
199
361
|
}
|
|
200
362
|
case "Fail": {
|
|
363
|
+
job.cancelRequested = false;
|
|
201
364
|
job.state = "failed";
|
|
202
365
|
job.exit = outcome.exit;
|
|
203
366
|
job.finishedAt = now;
|
|
204
367
|
recordAttempt(job, "failed", now, outcome.exit);
|
|
368
|
+
releaseDedupe(job, now);
|
|
369
|
+
applyKeep(job, now);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case "Cancelled": {
|
|
373
|
+
job.cancelRequested = false;
|
|
374
|
+
job.state = "cancelled";
|
|
375
|
+
job.finishedAt = now;
|
|
376
|
+
recordAttempt(job, "cancelled", now, undefined);
|
|
377
|
+
releaseDedupe(job, now);
|
|
205
378
|
applyKeep(job, now);
|
|
206
379
|
break;
|
|
207
380
|
}
|
|
208
381
|
case "Retry": {
|
|
382
|
+
if (job.cancelRequested) {
|
|
383
|
+
// A cancel raced a natural failure before the heartbeat could
|
|
384
|
+
// interrupt the run: cancellation wins over revival (mirrors
|
|
385
|
+
// release/recoverStalled).
|
|
386
|
+
markCancelled(job, now);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
209
389
|
recordAttempt(job, "retried", now, outcome.exit);
|
|
210
390
|
job.runAt = now + Math.max(0, outcome.delayMs);
|
|
211
391
|
job.state = outcome.delayMs > 0 ? "delayed" : "waiting";
|
|
212
392
|
job.seq = ++seq;
|
|
213
|
-
signalWake();
|
|
393
|
+
signalWake(job.queue);
|
|
214
394
|
break;
|
|
215
395
|
}
|
|
216
396
|
}
|
|
217
397
|
}),
|
|
218
398
|
release: (id, token) => Effect.gen(function* () {
|
|
399
|
+
const now = yield* Clock.currentTimeMillis;
|
|
219
400
|
const job = jobs.get(id);
|
|
220
401
|
if (job === undefined) {
|
|
221
402
|
return yield* new JobNotFoundError({ jobId: id });
|
|
@@ -223,25 +404,36 @@ export const make = Effect.sync(() => {
|
|
|
223
404
|
if (job.state !== "active" || job.lockToken !== token) {
|
|
224
405
|
return yield* new LockLostError({ jobId: id });
|
|
225
406
|
}
|
|
407
|
+
if (job.cancelRequested) {
|
|
408
|
+
// A cancel arrived while the worker was shutting down: honour it
|
|
409
|
+
// instead of reviving the job.
|
|
410
|
+
markCancelled(job, now);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
226
413
|
clearLock(job);
|
|
227
414
|
job.state = "waiting";
|
|
228
|
-
signalWake();
|
|
415
|
+
signalWake(job.queue);
|
|
229
416
|
}),
|
|
230
417
|
extendLocks: (locks, durationMs) => Effect.gen(function* () {
|
|
231
418
|
const now = yield* Clock.currentTimeMillis;
|
|
232
419
|
const lost = [];
|
|
420
|
+
const cancelRequested = [];
|
|
233
421
|
for (const lock of locks) {
|
|
234
422
|
const job = jobs.get(lock.id);
|
|
235
|
-
if (job
|
|
236
|
-
job.state
|
|
237
|
-
job.lockToken
|
|
238
|
-
|
|
423
|
+
if (job === undefined ||
|
|
424
|
+
job.state !== "active" ||
|
|
425
|
+
job.lockToken !== lock.token) {
|
|
426
|
+
lost.push(lock.id);
|
|
427
|
+
}
|
|
428
|
+
else if (job.cancelRequested) {
|
|
429
|
+
cancelRequested.push(lock.id);
|
|
239
430
|
}
|
|
240
431
|
else {
|
|
241
|
-
|
|
432
|
+
job.lockExpiresAt = now + durationMs;
|
|
242
433
|
}
|
|
243
434
|
}
|
|
244
|
-
|
|
435
|
+
const result = { lost, cancelRequested };
|
|
436
|
+
return result;
|
|
245
437
|
}),
|
|
246
438
|
recoverStalled: (options) => Effect.gen(function* () {
|
|
247
439
|
const now = yield* Clock.currentTimeMillis;
|
|
@@ -252,6 +444,11 @@ export const make = Effect.sync(() => {
|
|
|
252
444
|
job.lockExpiresAt > now) {
|
|
253
445
|
continue;
|
|
254
446
|
}
|
|
447
|
+
if (job.cancelRequested) {
|
|
448
|
+
// The owning worker died before honouring the cancel: finish it.
|
|
449
|
+
markCancelled(job, now);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
255
452
|
clearLock(job);
|
|
256
453
|
job.stalledCount += 1;
|
|
257
454
|
recordAttempt(job, "stalled", now, undefined);
|
|
@@ -259,6 +456,7 @@ export const make = Effect.sync(() => {
|
|
|
259
456
|
job.state = "failed";
|
|
260
457
|
job.finishedAt = now;
|
|
261
458
|
job.failedReason = "job stalled more than allowable limit";
|
|
459
|
+
releaseDedupe(job, now);
|
|
262
460
|
recovered.push({ id: job.id, failed: true });
|
|
263
461
|
}
|
|
264
462
|
else {
|
|
@@ -271,10 +469,12 @@ export const make = Effect.sync(() => {
|
|
|
271
469
|
}
|
|
272
470
|
return recovered;
|
|
273
471
|
}),
|
|
274
|
-
awaitWake: (
|
|
275
|
-
if (
|
|
472
|
+
awaitWake: (queues, wakeToken) => Effect.suspend(() => {
|
|
473
|
+
if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
|
|
276
474
|
return Effect.void;
|
|
277
|
-
|
|
475
|
+
const waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe() };
|
|
476
|
+
waiters.add(waiter);
|
|
477
|
+
return Deferred.await(waiter.deferred).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(waiter))));
|
|
278
478
|
}),
|
|
279
479
|
getJob: (id) => Effect.sync(() => {
|
|
280
480
|
const job = jobs.get(id);
|
|
@@ -333,13 +533,84 @@ export const make = Effect.sync(() => {
|
|
|
333
533
|
job.state = "waiting";
|
|
334
534
|
job.attemptsMade = 0;
|
|
335
535
|
job.stalledCount = 0;
|
|
536
|
+
job.cancelRequested = false;
|
|
336
537
|
job.exit = undefined;
|
|
337
538
|
job.failedReason = undefined;
|
|
338
539
|
job.finishedAt = undefined;
|
|
339
540
|
job.processedAt = undefined;
|
|
340
541
|
job.runAt = now;
|
|
341
542
|
job.seq = ++seq;
|
|
342
|
-
signalWake();
|
|
543
|
+
signalWake(job.queue);
|
|
544
|
+
}),
|
|
545
|
+
cancel: (id) => Effect.gen(function* () {
|
|
546
|
+
const now = yield* Clock.currentTimeMillis;
|
|
547
|
+
const job = jobs.get(id);
|
|
548
|
+
if (job === undefined) {
|
|
549
|
+
return yield* new JobNotFoundError({ jobId: id });
|
|
550
|
+
}
|
|
551
|
+
switch (job.state) {
|
|
552
|
+
case "waiting":
|
|
553
|
+
case "delayed": {
|
|
554
|
+
markCancelled(job, now);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
case "active": {
|
|
558
|
+
job.cancelRequested = true;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
default: {
|
|
562
|
+
return yield* new JobNotCancellableError({ jobId: id, state: job.state });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}),
|
|
566
|
+
promote: (id) => Effect.gen(function* () {
|
|
567
|
+
const now = yield* Clock.currentTimeMillis;
|
|
568
|
+
const job = jobs.get(id);
|
|
569
|
+
if (job === undefined) {
|
|
570
|
+
return yield* new JobNotFoundError({ jobId: id });
|
|
571
|
+
}
|
|
572
|
+
if (job.state !== "delayed") {
|
|
573
|
+
return yield* new JobNotPromotableError({ jobId: id, state: job.state });
|
|
574
|
+
}
|
|
575
|
+
job.state = "waiting";
|
|
576
|
+
job.runAt = now;
|
|
577
|
+
signalWake(job.queue);
|
|
578
|
+
}),
|
|
579
|
+
pause: (queue) => Effect.sync(() => {
|
|
580
|
+
paused.add(queue);
|
|
581
|
+
}),
|
|
582
|
+
resume: (queue) => Effect.sync(() => {
|
|
583
|
+
if (paused.delete(queue)) {
|
|
584
|
+
signalWake(queue);
|
|
585
|
+
}
|
|
586
|
+
}),
|
|
587
|
+
pausedQueues: () => Effect.sync(() => Array.from(paused)),
|
|
588
|
+
upsertSchedule: (schedule) => Effect.sync(() => {
|
|
589
|
+
// An unchanged cadence keeps its next occurrence: deploy-time
|
|
590
|
+
// re-registration must not re-anchor `every` grids or drop a pending
|
|
591
|
+
// catch-up run. A changed cadence takes the caller's fresh nextRunAt.
|
|
592
|
+
const existing = schedules.get(schedule.key);
|
|
593
|
+
const sameCadence = existing !== undefined &&
|
|
594
|
+
existing.cron === schedule.cron &&
|
|
595
|
+
existing.tz === schedule.tz &&
|
|
596
|
+
existing.everyMs === schedule.everyMs;
|
|
597
|
+
schedules.set(schedule.key, sameCadence ? { ...schedule, nextRunAt: existing.nextRunAt } : schedule);
|
|
598
|
+
signalWake(schedule.queue);
|
|
599
|
+
}),
|
|
600
|
+
removeSchedule: (key) => Effect.sync(() => schedules.delete(key)),
|
|
601
|
+
listSchedules: (options) => Effect.sync(() => Array.from(schedules.values()).filter((schedule) => (options?.jobName === undefined || schedule.jobName === options.jobName) &&
|
|
602
|
+
(options?.queue === undefined || schedule.queue === options.queue))),
|
|
603
|
+
dueSchedules: () => Effect.gen(function* () {
|
|
604
|
+
const now = yield* Clock.currentTimeMillis;
|
|
605
|
+
return Array.from(schedules.values())
|
|
606
|
+
.filter((schedule) => schedule.nextRunAt <= now)
|
|
607
|
+
.toSorted((a, b) => a.nextRunAt - b.nextRunAt);
|
|
608
|
+
}),
|
|
609
|
+
advanceSchedule: (key, expectedRunAt, nextRunAt) => Effect.sync(() => {
|
|
610
|
+
const schedule = schedules.get(key);
|
|
611
|
+
if (schedule !== undefined && schedule.nextRunAt === expectedRunAt) {
|
|
612
|
+
schedules.set(key, { ...schedule, nextRunAt });
|
|
613
|
+
}
|
|
343
614
|
}),
|
|
344
615
|
counts: (queue) => Effect.sync(() => {
|
|
345
616
|
const counts = {
|
|
@@ -347,7 +618,8 @@ export const make = Effect.sync(() => {
|
|
|
347
618
|
delayed: 0,
|
|
348
619
|
active: 0,
|
|
349
620
|
completed: 0,
|
|
350
|
-
failed: 0
|
|
621
|
+
failed: 0,
|
|
622
|
+
cancelled: 0
|
|
351
623
|
};
|
|
352
624
|
for (const job of jobs.values()) {
|
|
353
625
|
if (queue !== undefined && job.queue !== queue)
|
|
@@ -364,18 +636,50 @@ export const make = Effect.sync(() => {
|
|
|
364
636
|
return true;
|
|
365
637
|
})
|
|
366
638
|
});
|
|
639
|
+
return { service, sweepHistory };
|
|
640
|
+
};
|
|
641
|
+
/**
|
|
642
|
+
* Build a fresh in-memory `JobStore` implementation (no history sweeper —
|
|
643
|
+
* use `makeWith` for `historyTtl` support).
|
|
644
|
+
*
|
|
645
|
+
* @since 0.1.0
|
|
646
|
+
*/
|
|
647
|
+
export const make = Effect.sync(() => makeStoreUnsafe().service);
|
|
648
|
+
/**
|
|
649
|
+
* Build a fresh in-memory `JobStore` with options; the history sweeper (when
|
|
650
|
+
* configured) lives in the surrounding `Scope`.
|
|
651
|
+
*
|
|
652
|
+
* @since 0.2.0
|
|
653
|
+
*/
|
|
654
|
+
export const makeWith = (options) => Effect.gen(function* () {
|
|
655
|
+
const { service, sweepHistory } = makeStoreUnsafe(options);
|
|
656
|
+
if (options?.historyTtl !== undefined) {
|
|
657
|
+
const ttlByState = normalizeHistoryTtl(options.historyTtl);
|
|
658
|
+
const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute");
|
|
659
|
+
yield* Effect.gen(function* () {
|
|
660
|
+
yield* Effect.sleep(intervalMs);
|
|
661
|
+
const now = yield* Clock.currentTimeMillis;
|
|
662
|
+
sweepHistory(now, ttlByState);
|
|
663
|
+
}).pipe(Effect.forever, Effect.forkScoped);
|
|
664
|
+
}
|
|
665
|
+
return service;
|
|
367
666
|
});
|
|
368
667
|
/**
|
|
369
|
-
* A fresh in-memory `JobStore` layer.
|
|
370
|
-
* `JobStore.named(...)` slot instead of the default.
|
|
668
|
+
* A fresh in-memory `JobStore` layer.
|
|
371
669
|
*
|
|
372
670
|
* @since 0.1.0
|
|
373
671
|
*/
|
|
374
|
-
export const layer = Layer.effect(JobStore,
|
|
672
|
+
export const layer = Layer.effect(JobStore, makeWith());
|
|
673
|
+
/**
|
|
674
|
+
* An in-memory layer with options (e.g. `historyTtl`).
|
|
675
|
+
*
|
|
676
|
+
* @since 0.2.0
|
|
677
|
+
*/
|
|
678
|
+
export const layerWith = (options) => Layer.effect(JobStore, makeWith(options));
|
|
375
679
|
/**
|
|
376
680
|
* An in-memory layer for a specific store key.
|
|
377
681
|
*
|
|
378
682
|
* @since 0.1.0
|
|
379
683
|
*/
|
|
380
|
-
export const layerFor = (store) => Layer.effect(store,
|
|
684
|
+
export const layerFor = (store, options) => Layer.effect(store, makeWith(options));
|
|
381
685
|
//# sourceMappingURL=MemoryJobStore.js.map
|