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/src/JobStore.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @since 0.1.0
|
|
18
18
|
*/
|
|
19
|
-
import { Brand, Context, Data, type Effect, type Option, Predicate } from "effect"
|
|
19
|
+
import { Brand, Context, Cron, Data, Duration, type Effect, type Option, Predicate, Result } from "effect"
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* The identifier of an enqueued job. Produced by `enqueue` (either
|
|
@@ -47,6 +47,20 @@ export type QueueName = Brand.Branded<string, "effect-mq/QueueName">
|
|
|
47
47
|
*/
|
|
48
48
|
export const QueueName: Brand.Constructor<QueueName> = Brand.nominal<QueueName>()
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The identifier of a repeatable-job schedule.
|
|
52
|
+
*
|
|
53
|
+
* @since 0.2.0
|
|
54
|
+
*/
|
|
55
|
+
export type ScheduleKey = Brand.Branded<string, "effect-mq/ScheduleKey">
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Brand a raw string as a `ScheduleKey`.
|
|
59
|
+
*
|
|
60
|
+
* @since 0.2.0
|
|
61
|
+
*/
|
|
62
|
+
export const ScheduleKey: Brand.Constructor<ScheduleKey> = Brand.nominal<ScheduleKey>()
|
|
63
|
+
|
|
50
64
|
/**
|
|
51
65
|
* The lifecycle states of a job.
|
|
52
66
|
*
|
|
@@ -57,7 +71,13 @@ export const QueueName: Brand.Constructor<QueueName> = Brand.nominal<QueueName>(
|
|
|
57
71
|
*
|
|
58
72
|
* @since 0.1.0
|
|
59
73
|
*/
|
|
60
|
-
export type JobState =
|
|
74
|
+
export type JobState =
|
|
75
|
+
| "waiting"
|
|
76
|
+
| "delayed"
|
|
77
|
+
| "active"
|
|
78
|
+
| "completed"
|
|
79
|
+
| "failed"
|
|
80
|
+
| "cancelled"
|
|
61
81
|
|
|
62
82
|
/**
|
|
63
83
|
* Retry backoff policy, persisted on the job record so any worker can route
|
|
@@ -75,19 +95,101 @@ export interface BackoffPolicy {
|
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
/**
|
|
78
|
-
* Retention
|
|
79
|
-
* record. Applied by the store after terminal acks, scoped to jobs with the
|
|
80
|
-
* same name and state. Default (undefined) keeps records forever.
|
|
98
|
+
* Retention rule for one terminal state's records (per job name).
|
|
81
99
|
*
|
|
82
|
-
* @since 0.
|
|
100
|
+
* @since 0.3.0
|
|
83
101
|
*/
|
|
84
|
-
export interface
|
|
102
|
+
export interface KeepStatePolicy {
|
|
85
103
|
/** Keep at most this many terminal records (per name + state). */
|
|
86
104
|
readonly count?: number | undefined
|
|
87
105
|
/** Remove terminal records older than this many milliseconds. */
|
|
88
106
|
readonly ageMs?: number | undefined
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Retention policy persisted on the record, split by terminal state —
|
|
111
|
+
* completed jobs are usually noise, failed ones evidence. Applied by the
|
|
112
|
+
* store after terminal acks (scoped to jobs with the same name and state),
|
|
113
|
+
* and honoured by the store's periodic history sweep when one is configured.
|
|
114
|
+
* An absent state keeps its records until the store's `historyTtl` ceiling
|
|
115
|
+
* (forever without one). Producers accept a flat `{ count, age }` shorthand
|
|
116
|
+
* and normalize it to all three states before it reaches a driver.
|
|
117
|
+
*
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export interface KeepPolicy {
|
|
121
|
+
readonly completed?: KeepStatePolicy | undefined
|
|
122
|
+
readonly failed?: KeepStatePolicy | undefined
|
|
123
|
+
readonly cancelled?: KeepStatePolicy | undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The terminal states retention applies to.
|
|
128
|
+
*
|
|
129
|
+
* @since 0.3.0
|
|
130
|
+
*/
|
|
131
|
+
export type TerminalState = "completed" | "failed" | "cancelled"
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Store-level retention ceiling: one duration for all terminal states, or a
|
|
135
|
+
* per-state split (an absent state is never swept by the timer).
|
|
136
|
+
*
|
|
137
|
+
* @since 0.3.0
|
|
138
|
+
*/
|
|
139
|
+
export type HistoryTtlInput =
|
|
140
|
+
| Duration.Input
|
|
141
|
+
| {
|
|
142
|
+
readonly completed?: Duration.Input | undefined
|
|
143
|
+
readonly failed?: Duration.Input | undefined
|
|
144
|
+
readonly cancelled?: Duration.Input | undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `HistoryTtlInput` normalized to milliseconds per terminal state.
|
|
149
|
+
*
|
|
150
|
+
* @since 0.3.0
|
|
151
|
+
*/
|
|
152
|
+
export interface HistoryTtlByState {
|
|
153
|
+
readonly completed?: number | undefined
|
|
154
|
+
readonly failed?: number | undefined
|
|
155
|
+
readonly cancelled?: number | undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Normalize a `HistoryTtlInput` to milliseconds per terminal state.
|
|
160
|
+
*
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
export const normalizeHistoryTtl = (
|
|
164
|
+
input: HistoryTtlInput
|
|
165
|
+
): HistoryTtlByState => {
|
|
166
|
+
if (Predicate.isObject(input) && !Duration.isDuration(input)) {
|
|
167
|
+
if (!("completed" in input || "failed" in input || "cancelled" in input)) {
|
|
168
|
+
// {} or a DurationObject would silently normalize to a 0ms ceiling and
|
|
169
|
+
// wipe all history — refuse loudly; use "90 days"-style inputs.
|
|
170
|
+
throw new Error(
|
|
171
|
+
"effect-mq: historyTtl object must set at least one of completed/failed/cancelled"
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
// SAFETY: Duration inputs (numbers, strings, bigints, tuples, Duration
|
|
175
|
+
// instances, DurationObjects) never carry terminal-state keys, so this
|
|
176
|
+
// is the per-state split form.
|
|
177
|
+
const split = input as {
|
|
178
|
+
readonly completed?: Duration.Input | undefined
|
|
179
|
+
readonly failed?: Duration.Input | undefined
|
|
180
|
+
readonly cancelled?: Duration.Input | undefined
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
completed: split.completed !== undefined ? Duration.toMillis(split.completed) : undefined,
|
|
184
|
+
failed: split.failed !== undefined ? Duration.toMillis(split.failed) : undefined,
|
|
185
|
+
cancelled: split.cancelled !== undefined ? Duration.toMillis(split.cancelled) : undefined
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// SAFETY: not the split form (checked above), so a plain Duration input.
|
|
189
|
+
const ms = Duration.toMillis(input as Duration.Input)
|
|
190
|
+
return { completed: ms, failed: ms, cancelled: ms }
|
|
191
|
+
}
|
|
192
|
+
|
|
91
193
|
/**
|
|
92
194
|
* One run of a job, persisted so the full run history is durable and
|
|
93
195
|
* inspectable — the storage-level analogue of `tapError` before a rerun,
|
|
@@ -103,7 +205,7 @@ export interface AttemptRecord {
|
|
|
103
205
|
readonly startedAt: number | undefined
|
|
104
206
|
/** Ack/recovery time of this run (epoch millis). */
|
|
105
207
|
readonly finishedAt: number
|
|
106
|
-
readonly outcome: "completed" | "retried" | "failed" | "stalled"
|
|
208
|
+
readonly outcome: "completed" | "retried" | "failed" | "stalled" | "cancelled"
|
|
107
209
|
/** Schema-encoded `Exit`; undefined for `stalled`. */
|
|
108
210
|
readonly exit: unknown
|
|
109
211
|
}
|
|
@@ -131,6 +233,15 @@ export interface JobRecord {
|
|
|
131
233
|
readonly stalledCount: number
|
|
132
234
|
readonly backoff: BackoffPolicy | undefined
|
|
133
235
|
readonly keep: KeepPolicy | undefined
|
|
236
|
+
/** Per-run execution time limit; the worker interrupts the handler past it. */
|
|
237
|
+
readonly timeoutMs: number | undefined
|
|
238
|
+
/**
|
|
239
|
+
* Set by `cancel` on an active job; the owning worker interrupts the
|
|
240
|
+
* handler on its next heartbeat and acks `Cancelled`.
|
|
241
|
+
*/
|
|
242
|
+
readonly cancelRequested: boolean
|
|
243
|
+
/** The dedup key this job was enqueued under, if any (see `DedupePolicy`). */
|
|
244
|
+
readonly dedupeKey: string | undefined
|
|
134
245
|
/** Epoch millis before which the job must not be claimed. */
|
|
135
246
|
readonly runAt: number
|
|
136
247
|
readonly enqueuedAt: number
|
|
@@ -142,6 +253,36 @@ export interface JobRecord {
|
|
|
142
253
|
readonly failedReason: string | undefined
|
|
143
254
|
}
|
|
144
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Deduplication policy carried on an enqueue request, scoped per job name.
|
|
258
|
+
* Dedup NEVER changes the job id — ids come from the caller, the idempotency
|
|
259
|
+
* key, or the store's id generator; the dedup key is a separate value with
|
|
260
|
+
* its own lifecycle:
|
|
261
|
+
*
|
|
262
|
+
* - `{ key }` (no ttl): dedupe while the keyed job is pending (waiting,
|
|
263
|
+
* delayed, or active); a terminal keyed job frees the key.
|
|
264
|
+
* - `{ key, ttlMs }`: throttle — at most one job per key per window,
|
|
265
|
+
* regardless of the keyed job's completion.
|
|
266
|
+
* - `extend` (requires `ttlMs`): each deduplicated enqueue pushes the window
|
|
267
|
+
* out (debounce).
|
|
268
|
+
* - `replace`: while the keyed job is still *delayed*, the new enqueue's
|
|
269
|
+
* payload/metadata/priority/attempts/backoff/keep/timeout/delay replace the
|
|
270
|
+
* existing job's (id and ledger preserved), and a `ttlMs` window is
|
|
271
|
+
* re-armed from the replace; in every other state normal dedup applies.
|
|
272
|
+
*
|
|
273
|
+
* A deduplicated (or replaced) enqueue returns the keyed job's id with
|
|
274
|
+
* `duplicate: true`.
|
|
275
|
+
*
|
|
276
|
+
* @since 0.3.0
|
|
277
|
+
*/
|
|
278
|
+
export interface DedupePolicy {
|
|
279
|
+
/** Non-empty; producers validate before the request reaches a driver. */
|
|
280
|
+
readonly key: string
|
|
281
|
+
readonly ttlMs: number | undefined
|
|
282
|
+
readonly extend: boolean
|
|
283
|
+
readonly replace: boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
145
286
|
/**
|
|
146
287
|
* @since 0.1.0
|
|
147
288
|
*/
|
|
@@ -160,9 +301,29 @@ export interface EnqueueRequest {
|
|
|
160
301
|
readonly attemptsMax: number
|
|
161
302
|
readonly backoff: BackoffPolicy | undefined
|
|
162
303
|
readonly keep: KeepPolicy | undefined
|
|
304
|
+
readonly timeoutMs: number | undefined
|
|
305
|
+
/** Deduplicate against other enqueues sharing `dedupe.key` (same name). */
|
|
306
|
+
readonly dedupe: DedupePolicy | undefined
|
|
163
307
|
readonly delayMs: number
|
|
164
308
|
}
|
|
165
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Generator for store-assigned job ids, used when `EnqueueRequest.id` is
|
|
312
|
+
* undefined (custom ids and idempotency keys always win, and schedule tick
|
|
313
|
+
* ids stay slot-deterministic). May be effectful (e.g. draw from Effect's
|
|
314
|
+
* `Random`). The store retries a bounded number of times when a generated id
|
|
315
|
+
* collides with an existing job, then fails the enqueue with
|
|
316
|
+
* `JobStoreError` — generators must have enough entropy that collisions are
|
|
317
|
+
* pathological, not routine.
|
|
318
|
+
*
|
|
319
|
+
* @example `({ name }) => \`${name}_${ulid()}\``
|
|
320
|
+
*
|
|
321
|
+
* @since 0.2.0
|
|
322
|
+
*/
|
|
323
|
+
export type IdGenerator = (
|
|
324
|
+
request: EnqueueRequest
|
|
325
|
+
) => string | Effect.Effect<string>
|
|
326
|
+
|
|
166
327
|
/**
|
|
167
328
|
* @since 0.1.0
|
|
168
329
|
*/
|
|
@@ -213,6 +374,7 @@ export type AckOutcome =
|
|
|
213
374
|
| { readonly _tag: "Complete"; readonly exit: unknown }
|
|
214
375
|
| { readonly _tag: "Retry"; readonly delayMs: number; readonly exit: unknown }
|
|
215
376
|
| { readonly _tag: "Fail"; readonly exit: unknown }
|
|
377
|
+
| { readonly _tag: "Cancelled" }
|
|
216
378
|
|
|
217
379
|
/**
|
|
218
380
|
* Filters and pagination for `list`. Results are ordered newest-first
|
|
@@ -241,6 +403,45 @@ export interface ListResult {
|
|
|
241
403
|
readonly cursor: string | undefined
|
|
242
404
|
}
|
|
243
405
|
|
|
406
|
+
/**
|
|
407
|
+
* A repeatable-job schedule as persisted by the store. Exactly one of `cron`
|
|
408
|
+
* (with optional IANA `tz`) or `everyMs` is set. The payload is stored
|
|
409
|
+
* schema-encoded, like job payloads. `nextRunAt` is maintained by the worker
|
|
410
|
+
* sweep via `advanceSchedule`; ticks enqueue with the deterministic id
|
|
411
|
+
* `sched/<key>/<slot>`, so concurrent sweepers dedup naturally.
|
|
412
|
+
*
|
|
413
|
+
* @since 0.2.0
|
|
414
|
+
*/
|
|
415
|
+
export interface ScheduleRecord {
|
|
416
|
+
readonly key: ScheduleKey
|
|
417
|
+
readonly jobName: string
|
|
418
|
+
readonly queue: QueueName
|
|
419
|
+
readonly cron: string | undefined
|
|
420
|
+
readonly tz: string | undefined
|
|
421
|
+
readonly everyMs: number | undefined
|
|
422
|
+
readonly payload: unknown
|
|
423
|
+
readonly metadata: Readonly<Record<string, string>>
|
|
424
|
+
readonly priority: number
|
|
425
|
+
readonly attemptsMax: number
|
|
426
|
+
readonly backoff: BackoffPolicy | undefined
|
|
427
|
+
readonly keep: KeepPolicy | undefined
|
|
428
|
+
readonly timeoutMs: number | undefined
|
|
429
|
+
/** Epoch millis of the next occurrence to enqueue. */
|
|
430
|
+
readonly nextRunAt: number
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Result of a heartbeat: locks that could not be extended (lost to stall
|
|
435
|
+
* recovery or another worker) and active jobs with a pending cancel request
|
|
436
|
+
* (the worker must interrupt them and ack `Cancelled`).
|
|
437
|
+
*
|
|
438
|
+
* @since 0.2.0
|
|
439
|
+
*/
|
|
440
|
+
export interface ExtendLocksResult {
|
|
441
|
+
readonly lost: ReadonlyArray<JobId>
|
|
442
|
+
readonly cancelRequested: ReadonlyArray<JobId>
|
|
443
|
+
}
|
|
444
|
+
|
|
244
445
|
/**
|
|
245
446
|
* A transient or fatal driver error (connection loss, serialization, etc.).
|
|
246
447
|
*
|
|
@@ -260,6 +461,73 @@ export class JobStoreError extends Data.TaggedError("JobStoreError")<{
|
|
|
260
461
|
export const isJobStoreError = (u: unknown): u is JobStoreError =>
|
|
261
462
|
Predicate.hasProperty(u, "_tag") && u._tag === "JobStoreError"
|
|
262
463
|
|
|
464
|
+
// Identity-based marking (WeakSet, works on frozen error instances) so the
|
|
465
|
+
// error keeps its declared type and schema round-trip untouched.
|
|
466
|
+
const unrecoverableRegistry = new WeakSet<object>()
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Mark an error value as unrecoverable: when a handler fails with it, the
|
|
470
|
+
* worker skips the remaining retry budget and fails the job immediately. The
|
|
471
|
+
* error itself is returned unchanged, so typed error channels and schemas
|
|
472
|
+
* are unaffected. Also exported as `Job.unrecoverable`.
|
|
473
|
+
*
|
|
474
|
+
* Marking is identity-based, so it only works for object errors — a
|
|
475
|
+
* primitive (string/number) failure is returned unmarked and retries
|
|
476
|
+
* normally; use the definition-level `retryable` predicate for those.
|
|
477
|
+
*
|
|
478
|
+
* @since 0.2.0
|
|
479
|
+
*/
|
|
480
|
+
export const unrecoverable = <E>(error: E): E => {
|
|
481
|
+
if (Object(error) === error) {
|
|
482
|
+
// SAFETY: `Object(x) === x` is true exactly for object values, which is
|
|
483
|
+
// what WeakSet membership requires.
|
|
484
|
+
unrecoverableRegistry.add(error as object)
|
|
485
|
+
}
|
|
486
|
+
return error
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Whether `unrecoverable` marked this value.
|
|
491
|
+
*
|
|
492
|
+
* @internal
|
|
493
|
+
*/
|
|
494
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- boundary classifier over arbitrary error values
|
|
495
|
+
export const isMarkedUnrecoverable = (u: unknown): boolean =>
|
|
496
|
+
// SAFETY: `Object(u) === u` short-circuits to false for primitives, so the
|
|
497
|
+
// assertion only ever passes object values to the WeakSet.
|
|
498
|
+
Object(u) === u && unrecoverableRegistry.has(u as object)
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The next occurrence of a schedule strictly after `now`. `fromSlot` anchors
|
|
502
|
+
* `everyMs` schedules (occurrences stay on the `slot + k * every` grid).
|
|
503
|
+
* Returns undefined for an invalid cron expression.
|
|
504
|
+
*
|
|
505
|
+
* @internal
|
|
506
|
+
*/
|
|
507
|
+
export const nextOccurrence = (
|
|
508
|
+
schedule: Pick<ScheduleRecord, "cron" | "tz" | "everyMs">,
|
|
509
|
+
fromSlot: number,
|
|
510
|
+
now: number
|
|
511
|
+
): number | undefined => {
|
|
512
|
+
if (schedule.cron !== undefined) {
|
|
513
|
+
const parsed = Cron.parse(schedule.cron, schedule.tz)
|
|
514
|
+
if (Result.isFailure(parsed)) return undefined
|
|
515
|
+
// Cron.next throws for parseable-but-unsatisfiable expressions (e.g.
|
|
516
|
+
// "0 0 30 2 *"); treat those as invalid rather than defecting the sweep.
|
|
517
|
+
try {
|
|
518
|
+
return Cron.next(parsed.success, new Date(Math.max(fromSlot, now))).getTime()
|
|
519
|
+
} catch {
|
|
520
|
+
return undefined
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (schedule.everyMs !== undefined && schedule.everyMs > 0) {
|
|
524
|
+
const behind = Math.max(0, now - fromSlot)
|
|
525
|
+
const steps = Math.floor(behind / schedule.everyMs) + 1
|
|
526
|
+
return fromSlot + steps * schedule.everyMs
|
|
527
|
+
}
|
|
528
|
+
return undefined
|
|
529
|
+
}
|
|
530
|
+
|
|
263
531
|
|
|
264
532
|
/**
|
|
265
533
|
* The presented lock token no longer owns the job (it stalled and was
|
|
@@ -288,6 +556,35 @@ export class JobNotRetryableError extends Data.TaggedError("JobNotRetryableError
|
|
|
288
556
|
readonly state: JobState
|
|
289
557
|
}> {}
|
|
290
558
|
|
|
559
|
+
/**
|
|
560
|
+
* `cancel` was called on a job that is already terminal.
|
|
561
|
+
*
|
|
562
|
+
* @since 0.2.0
|
|
563
|
+
*/
|
|
564
|
+
export class JobNotCancellableError extends Data.TaggedError("JobNotCancellableError")<{
|
|
565
|
+
readonly jobId: JobId
|
|
566
|
+
readonly state: JobState
|
|
567
|
+
}> {}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* `promote` was called on a job that is not in the `delayed` state.
|
|
571
|
+
*
|
|
572
|
+
* @since 0.2.0
|
|
573
|
+
*/
|
|
574
|
+
export class JobNotPromotableError extends Data.TaggedError("JobNotPromotableError")<{
|
|
575
|
+
readonly jobId: JobId
|
|
576
|
+
readonly state: JobState
|
|
577
|
+
}> {}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Raised (as a defect) by `awaitResult` when the awaited job was cancelled.
|
|
581
|
+
*
|
|
582
|
+
* @since 0.2.0
|
|
583
|
+
*/
|
|
584
|
+
export class JobCancelledError extends Data.TaggedError("JobCancelledError")<{
|
|
585
|
+
readonly jobId: JobId
|
|
586
|
+
}> {}
|
|
587
|
+
|
|
291
588
|
/**
|
|
292
589
|
* The service shape every store implements.
|
|
293
590
|
*
|
|
@@ -339,7 +636,7 @@ export interface Service {
|
|
|
339
636
|
readonly extendLocks: (
|
|
340
637
|
locks: ReadonlyArray<{ readonly id: JobId; readonly token: string }>,
|
|
341
638
|
durationMs: number
|
|
342
|
-
) => Effect.Effect<
|
|
639
|
+
) => Effect.Effect<ExtendLocksResult, JobStoreError>
|
|
343
640
|
|
|
344
641
|
/**
|
|
345
642
|
* Sweep active jobs whose lock has expired. Each recovered job gets
|
|
@@ -391,6 +688,73 @@ export interface Service {
|
|
|
391
688
|
JobStoreError | JobNotFoundError | JobNotRetryableError
|
|
392
689
|
>
|
|
393
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Cancel a job. Waiting/delayed jobs become terminal (`cancelled`)
|
|
693
|
+
* immediately; active jobs get `cancelRequested` set, and the owning
|
|
694
|
+
* worker interrupts the handler on its next heartbeat. Terminal jobs fail
|
|
695
|
+
* with `JobNotCancellableError`.
|
|
696
|
+
*/
|
|
697
|
+
readonly cancel: (
|
|
698
|
+
id: JobId
|
|
699
|
+
) => Effect.Effect<
|
|
700
|
+
void,
|
|
701
|
+
JobStoreError | JobNotFoundError | JobNotCancellableError
|
|
702
|
+
>
|
|
703
|
+
|
|
704
|
+
/** Move a delayed job to `waiting` now. */
|
|
705
|
+
readonly promote: (
|
|
706
|
+
id: JobId
|
|
707
|
+
) => Effect.Effect<
|
|
708
|
+
void,
|
|
709
|
+
JobStoreError | JobNotFoundError | JobNotPromotableError
|
|
710
|
+
>
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Durably pause a queue: claims return `Empty` until `resume` (delayed
|
|
714
|
+
* jobs still promote to `waiting`, they just aren't handed out). Affects
|
|
715
|
+
* every worker on the store.
|
|
716
|
+
*/
|
|
717
|
+
readonly pause: (queue: QueueName) => Effect.Effect<void, JobStoreError>
|
|
718
|
+
|
|
719
|
+
/** Undo `pause` and wake idle workers. */
|
|
720
|
+
readonly resume: (queue: QueueName) => Effect.Effect<void, JobStoreError>
|
|
721
|
+
|
|
722
|
+
readonly pausedQueues: () => Effect.Effect<
|
|
723
|
+
ReadonlyArray<QueueName>,
|
|
724
|
+
JobStoreError
|
|
725
|
+
>
|
|
726
|
+
|
|
727
|
+
/** Create or replace a repeatable-job schedule (keyed by `schedule.key`). */
|
|
728
|
+
readonly upsertSchedule: (
|
|
729
|
+
schedule: ScheduleRecord
|
|
730
|
+
) => Effect.Effect<void, JobStoreError>
|
|
731
|
+
|
|
732
|
+
/** Remove a schedule. Returns false when the key does not exist. */
|
|
733
|
+
readonly removeSchedule: (
|
|
734
|
+
key: ScheduleKey
|
|
735
|
+
) => Effect.Effect<boolean, JobStoreError>
|
|
736
|
+
|
|
737
|
+
readonly listSchedules: (options?: {
|
|
738
|
+
readonly jobName?: string | undefined
|
|
739
|
+
readonly queue?: QueueName | undefined
|
|
740
|
+
}) => Effect.Effect<ReadonlyArray<ScheduleRecord>, JobStoreError>
|
|
741
|
+
|
|
742
|
+
/** Schedules whose `nextRunAt` is due (per the Effect `Clock`). */
|
|
743
|
+
readonly dueSchedules: () => Effect.Effect<
|
|
744
|
+
ReadonlyArray<ScheduleRecord>,
|
|
745
|
+
JobStoreError
|
|
746
|
+
>
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Advance a schedule's `nextRunAt` from `expectedRunAt` to `nextRunAt`
|
|
750
|
+
* (conditional, so concurrent sweepers cannot regress it).
|
|
751
|
+
*/
|
|
752
|
+
readonly advanceSchedule: (
|
|
753
|
+
key: ScheduleKey,
|
|
754
|
+
expectedRunAt: number,
|
|
755
|
+
nextRunAt: number
|
|
756
|
+
) => Effect.Effect<void, JobStoreError>
|
|
757
|
+
|
|
394
758
|
readonly counts: (
|
|
395
759
|
queue?: QueueName
|
|
396
760
|
) => Effect.Effect<Record<JobState, number>, JobStoreError>
|