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.
Files changed (67) hide show
  1. package/README.md +305 -25
  2. package/dist/Job.d.ts +118 -5
  3. package/dist/Job.d.ts.map +1 -1
  4. package/dist/Job.js +119 -4
  5. package/dist/Job.js.map +1 -1
  6. package/dist/JobStore.d.ts +260 -9
  7. package/dist/JobStore.d.ts.map +1 -1
  8. package/dist/JobStore.js +115 -1
  9. package/dist/JobStore.js.map +1 -1
  10. package/dist/MemoryJobStore.d.ts +38 -6
  11. package/dist/MemoryJobStore.d.ts.map +1 -1
  12. package/dist/MemoryJobStore.js +351 -47
  13. package/dist/MemoryJobStore.js.map +1 -1
  14. package/dist/Worker.d.ts +5 -1
  15. package/dist/Worker.d.ts.map +1 -1
  16. package/dist/Worker.js +117 -10
  17. package/dist/Worker.js.map +1 -1
  18. package/dist/{drizzle → drizzle-postgres}/DrizzleJobStore.d.ts +35 -2
  19. package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -0
  20. package/dist/drizzle-postgres/DrizzleJobStore.js +941 -0
  21. package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -0
  22. package/dist/drizzle-postgres/index.d.ts.map +1 -0
  23. package/dist/drizzle-postgres/index.js.map +1 -0
  24. package/dist/drizzle-postgres/schema.d.ts +670 -0
  25. package/dist/drizzle-postgres/schema.d.ts.map +1 -0
  26. package/dist/drizzle-postgres/schema.js +150 -0
  27. package/dist/drizzle-postgres/schema.js.map +1 -0
  28. package/dist/redis/RedisJobStore.d.ts +58 -0
  29. package/dist/redis/RedisJobStore.d.ts.map +1 -0
  30. package/dist/redis/RedisJobStore.js +424 -0
  31. package/dist/redis/RedisJobStore.js.map +1 -0
  32. package/dist/redis/index.d.ts +9 -0
  33. package/dist/redis/index.d.ts.map +1 -0
  34. package/dist/redis/index.js +9 -0
  35. package/dist/redis/index.js.map +1 -0
  36. package/dist/redis/scripts.d.ts +181 -0
  37. package/dist/redis/scripts.d.ts.map +1 -0
  38. package/dist/redis/scripts.js +940 -0
  39. package/dist/redis/scripts.js.map +1 -0
  40. package/dist/testing/conformance.d.ts.map +1 -1
  41. package/dist/testing/conformance.js +502 -8
  42. package/dist/testing/conformance.js.map +1 -1
  43. package/package.json +8 -4
  44. package/src/Job.ts +301 -10
  45. package/src/JobStore.ts +373 -9
  46. package/src/MemoryJobStore.ts +440 -53
  47. package/src/Worker.ts +153 -10
  48. package/src/drizzle-postgres/DrizzleJobStore.ts +1311 -0
  49. package/src/drizzle-postgres/schema.ts +279 -0
  50. package/src/redis/RedisJobStore.ts +652 -0
  51. package/src/redis/index.ts +8 -0
  52. package/src/redis/scripts.ts +1055 -0
  53. package/src/testing/conformance.ts +665 -8
  54. package/dist/drizzle/DrizzleJobStore.d.ts.map +0 -1
  55. package/dist/drizzle/DrizzleJobStore.js +0 -426
  56. package/dist/drizzle/DrizzleJobStore.js.map +0 -1
  57. package/dist/drizzle/index.d.ts.map +0 -1
  58. package/dist/drizzle/index.js.map +0 -1
  59. package/dist/drizzle/schema.d.ts +0 -464
  60. package/dist/drizzle/schema.d.ts.map +0 -1
  61. package/dist/drizzle/schema.js +0 -68
  62. package/dist/drizzle/schema.js.map +0 -1
  63. package/src/drizzle/DrizzleJobStore.ts +0 -599
  64. package/src/drizzle/schema.ts +0 -116
  65. /package/dist/{drizzle → drizzle-postgres}/index.d.ts +0 -0
  66. /package/dist/{drizzle → drizzle-postgres}/index.js +0 -0
  67. /package/src/{drizzle → drizzle-postgres}/index.ts +0 -0
@@ -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 wake = Deferred.makeUnsafe();
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
- const signalWake = () => {
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
- const current = wake;
59
- wake = Deferred.makeUnsafe();
60
- Deferred.doneUnsafe(current, Exit.succeed(void 0));
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
- if (job.keep === undefined)
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 (job.keep.ageMs !== undefined) {
160
+ if (policy.ageMs !== undefined) {
92
161
  for (const peer of peers) {
93
- if (peer.finishedAt !== undefined && peer.finishedAt <= now - job.keep.ageMs) {
162
+ if (peer.finishedAt !== undefined && peer.finishedAt <= now - policy.ageMs) {
94
163
  remove.add(peer.id);
95
164
  }
96
165
  }
97
166
  }
98
- if (job.keep.count !== undefined) {
99
- for (const peer of peers.slice(Math.max(0, job.keep.count))) {
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
- return JobStore.of({
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
- // Store-assigned ids must never collide with user-supplied ones.
113
- do {
114
- id = JobId(`j-${++idCounter}`);
115
- } while (jobs.has(id));
116
- }
117
- else if (jobs.has(id)) {
118
- return { id, duplicate: true };
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
- signalWake();
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 !== undefined &&
236
- job.state === "active" &&
237
- job.lockToken === lock.token) {
238
- job.lockExpiresAt = now + durationMs;
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
- lost.push(lock.id);
432
+ job.lockExpiresAt = now + durationMs;
242
433
  }
243
434
  }
244
- return lost;
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: (_queues, wakeToken) => Effect.suspend(() => {
275
- if (wakeVersion > wakeToken)
472
+ awaitWake: (queues, wakeToken) => Effect.suspend(() => {
473
+ if (queues.some((queue) => lastWakeFor(queue) > wakeToken))
276
474
  return Effect.void;
277
- return Deferred.await(wake);
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. Pass a named store key to provide a
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, make);
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, make);
684
+ export const layerFor = (store, options) => Layer.effect(store, makeWith(options));
381
685
  //# sourceMappingURL=MemoryJobStore.js.map