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/MemoryJobStore.ts
CHANGED
|
@@ -10,20 +10,32 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @since 0.1.0
|
|
12
12
|
*/
|
|
13
|
-
import { Clock, type Context, Deferred, Effect, Exit, Layer, Option } from "effect"
|
|
13
|
+
import { Clock, type Context, Deferred, Duration, Effect, Exit, Layer, Option, type Scope } from "effect"
|
|
14
14
|
import {
|
|
15
15
|
type AttemptRecord,
|
|
16
16
|
type ClaimResult,
|
|
17
17
|
type EnqueueRequest,
|
|
18
|
+
type ExtendLocksResult,
|
|
19
|
+
type HistoryTtlByState,
|
|
20
|
+
type HistoryTtlInput,
|
|
21
|
+
type IdGenerator,
|
|
18
22
|
JobId,
|
|
23
|
+
JobNotCancellableError,
|
|
19
24
|
JobNotFoundError,
|
|
25
|
+
JobNotPromotableError,
|
|
20
26
|
JobNotRetryableError,
|
|
21
27
|
type JobRecord,
|
|
22
28
|
type JobState,
|
|
29
|
+
type KeepStatePolicy,
|
|
23
30
|
JobStore,
|
|
24
31
|
type ListResult,
|
|
25
32
|
LockLostError,
|
|
33
|
+
JobStoreError,
|
|
34
|
+
normalizeHistoryTtl,
|
|
35
|
+
type TerminalState,
|
|
26
36
|
type QueueName,
|
|
37
|
+
type ScheduleKey,
|
|
38
|
+
type ScheduleRecord,
|
|
27
39
|
type Service
|
|
28
40
|
} from "./JobStore.ts"
|
|
29
41
|
|
|
@@ -31,15 +43,18 @@ interface MemJob {
|
|
|
31
43
|
readonly id: JobId
|
|
32
44
|
readonly name: string
|
|
33
45
|
readonly queue: QueueName
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
payload: unknown
|
|
47
|
+
metadata: Readonly<Record<string, string>>
|
|
36
48
|
state: JobState
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
priority: number
|
|
50
|
+
attemptsMax: number
|
|
39
51
|
attemptsMade: number
|
|
40
52
|
stalledCount: number
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
backoff: JobRecord["backoff"]
|
|
54
|
+
keep: JobRecord["keep"]
|
|
55
|
+
timeoutMs: number | undefined
|
|
56
|
+
cancelRequested: boolean
|
|
57
|
+
readonly dedupeKey: string | undefined
|
|
43
58
|
runAt: number
|
|
44
59
|
readonly enqueuedAt: number
|
|
45
60
|
processedAt: number | undefined
|
|
@@ -52,6 +67,8 @@ interface MemJob {
|
|
|
52
67
|
lockExpiresAt: number | undefined
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
const TERMINAL_STATES: ReadonlySet<JobState> = new Set(["completed", "failed", "cancelled"])
|
|
71
|
+
|
|
55
72
|
const snapshot = (job: MemJob): JobRecord => ({
|
|
56
73
|
id: job.id,
|
|
57
74
|
name: job.name,
|
|
@@ -65,6 +82,9 @@ const snapshot = (job: MemJob): JobRecord => ({
|
|
|
65
82
|
stalledCount: job.stalledCount,
|
|
66
83
|
backoff: job.backoff,
|
|
67
84
|
keep: job.keep,
|
|
85
|
+
timeoutMs: job.timeoutMs,
|
|
86
|
+
cancelRequested: job.cancelRequested,
|
|
87
|
+
dedupeKey: job.dedupeKey,
|
|
68
88
|
runAt: job.runAt,
|
|
69
89
|
enqueuedAt: job.enqueuedAt,
|
|
70
90
|
processedAt: job.processedAt,
|
|
@@ -83,26 +103,55 @@ const metadataMatches = (
|
|
|
83
103
|
return true
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
interface MemoryStore {
|
|
107
|
+
readonly service: Service
|
|
108
|
+
readonly sweepHistory: (now: number, ttlByState: HistoryTtlByState) => void
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemoryStore => {
|
|
92
112
|
const jobs = new Map<string, MemJob>()
|
|
113
|
+
const schedules = new Map<ScheduleKey, ScheduleRecord>()
|
|
114
|
+
const paused = new Set<QueueName>()
|
|
115
|
+
// Dedup registry: one entry per (name, key). `expiresAt` is set for
|
|
116
|
+
// ttl/throttle windows; pending-mode entries live as long as their job.
|
|
117
|
+
const dedupes = new Map<string, { jobId: JobId; expiresAt: number | undefined }>()
|
|
118
|
+
const dedupeMapKey = (name: string, key: string) => `${name}\u0000${key}`
|
|
93
119
|
let seq = 0
|
|
94
120
|
let idCounter = 0
|
|
95
121
|
let wakeVersion = 0
|
|
96
|
-
let
|
|
122
|
+
let lastBroadcast = 0
|
|
123
|
+
const lastWake = new Map<QueueName, number>()
|
|
124
|
+
interface Waiter {
|
|
125
|
+
readonly queues: ReadonlySet<QueueName>
|
|
126
|
+
readonly deferred: Deferred.Deferred<void>
|
|
127
|
+
}
|
|
128
|
+
const waiters = new Set<Waiter>()
|
|
129
|
+
const lastWakeFor = (queue: QueueName) => Math.max(lastWake.get(queue) ?? 0, lastBroadcast)
|
|
97
130
|
|
|
98
131
|
// Synchronous on purpose: it is called inside the same synchronous block as
|
|
99
132
|
// the state mutation, so no effect-op boundary (where an interrupt could
|
|
100
|
-
// land) can separate a mutation from its wake-up signal.
|
|
101
|
-
|
|
133
|
+
// land) can separate a mutation from its wake-up signal. A queue targets
|
|
134
|
+
// only waiters watching it; no queue broadcasts (rare maintenance verbs).
|
|
135
|
+
const signalWake = (queue?: QueueName) => {
|
|
102
136
|
wakeVersion += 1
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
if (queue === undefined) {
|
|
138
|
+
lastBroadcast = wakeVersion
|
|
139
|
+
} else {
|
|
140
|
+
lastWake.set(queue, wakeVersion)
|
|
141
|
+
}
|
|
142
|
+
// Snapshot-and-clear BEFORE resolving: doneUnsafe resumes waiting fibers
|
|
143
|
+
// synchronously, and a woken taker that re-parks registers a NEW waiter —
|
|
144
|
+
// resolving inside the live Set iteration would visit it and livelock.
|
|
145
|
+
const toWake: Array<Waiter> = []
|
|
146
|
+
for (const waiter of waiters) {
|
|
147
|
+
if (queue === undefined || waiter.queues.has(queue)) {
|
|
148
|
+
waiters.delete(waiter)
|
|
149
|
+
toWake.push(waiter)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const waiter of toWake) {
|
|
153
|
+
Deferred.doneUnsafe(waiter.deferred, Exit.succeed<void>(void 0))
|
|
154
|
+
}
|
|
106
155
|
}
|
|
107
156
|
|
|
108
157
|
const promoteDue = (now: number) => {
|
|
@@ -133,23 +182,71 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
133
182
|
})
|
|
134
183
|
}
|
|
135
184
|
|
|
185
|
+
// A job leaving the pending states frees its pending-mode dedup entry;
|
|
186
|
+
// live throttle windows deliberately outlast the job.
|
|
187
|
+
const releaseDedupe = (job: MemJob, now: number) => {
|
|
188
|
+
if (job.dedupeKey === undefined) return
|
|
189
|
+
const key = dedupeMapKey(job.name, job.dedupeKey)
|
|
190
|
+
const entry = dedupes.get(key)
|
|
191
|
+
if (
|
|
192
|
+
entry !== undefined && entry.jobId === job.id &&
|
|
193
|
+
(entry.expiresAt === undefined || entry.expiresAt <= now)
|
|
194
|
+
) {
|
|
195
|
+
dedupes.delete(key)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const markCancelled = (job: MemJob, now: number) => {
|
|
200
|
+
clearLock(job)
|
|
201
|
+
job.cancelRequested = false
|
|
202
|
+
job.state = "cancelled"
|
|
203
|
+
job.finishedAt = now
|
|
204
|
+
recordAttempt(job, "cancelled", now, undefined)
|
|
205
|
+
releaseDedupe(job, now)
|
|
206
|
+
applyKeep(job, now)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const keepPolicyFor = (keep: JobRecord["keep"], state: JobState) => {
|
|
210
|
+
if (keep === undefined) return undefined
|
|
211
|
+
const policy = state === "completed"
|
|
212
|
+
? keep.completed
|
|
213
|
+
: state === "failed"
|
|
214
|
+
? keep.failed
|
|
215
|
+
: state === "cancelled"
|
|
216
|
+
? keep.cancelled
|
|
217
|
+
: undefined
|
|
218
|
+
if (policy !== undefined) return policy
|
|
219
|
+
// Records persisted by 0.2.x carry the flat {count, ageMs} shape — honour
|
|
220
|
+
// it as an all-states policy so upgrades keep pruning.
|
|
221
|
+
if (
|
|
222
|
+
keep.completed === undefined && keep.failed === undefined && keep.cancelled === undefined &&
|
|
223
|
+
("count" in keep || "ageMs" in keep)
|
|
224
|
+
) {
|
|
225
|
+
// SAFETY: the flat legacy shape carries KeepStatePolicy fields.
|
|
226
|
+
return keep as KeepStatePolicy
|
|
227
|
+
}
|
|
228
|
+
return undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
136
231
|
// Terminal retention: keep at most `count` and drop older than `ageMs`
|
|
137
|
-
// among terminal jobs sharing this job's name + state
|
|
232
|
+
// among terminal jobs sharing this job's name + state (policies are split
|
|
233
|
+
// per terminal state).
|
|
138
234
|
const applyKeep = (job: MemJob, now: number) => {
|
|
139
|
-
|
|
235
|
+
const policy = keepPolicyFor(job.keep, job.state)
|
|
236
|
+
if (policy === undefined) return
|
|
140
237
|
const peers = Array.from(jobs.values())
|
|
141
238
|
.filter((peer) => peer.name === job.name && peer.state === job.state)
|
|
142
239
|
.toSorted((a, b) => ((b.finishedAt ?? 0) - (a.finishedAt ?? 0)) || (b.seq - a.seq))
|
|
143
240
|
const remove = new Set<string>()
|
|
144
|
-
if (
|
|
241
|
+
if (policy.ageMs !== undefined) {
|
|
145
242
|
for (const peer of peers) {
|
|
146
|
-
if (peer.finishedAt !== undefined && peer.finishedAt <= now -
|
|
243
|
+
if (peer.finishedAt !== undefined && peer.finishedAt <= now - policy.ageMs) {
|
|
147
244
|
remove.add(peer.id)
|
|
148
245
|
}
|
|
149
246
|
}
|
|
150
247
|
}
|
|
151
|
-
if (
|
|
152
|
-
for (const peer of peers.slice(Math.max(0,
|
|
248
|
+
if (policy.count !== undefined) {
|
|
249
|
+
for (const peer of peers.slice(Math.max(0, policy.count))) {
|
|
153
250
|
remove.add(peer.id)
|
|
154
251
|
}
|
|
155
252
|
}
|
|
@@ -158,18 +255,99 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
158
255
|
}
|
|
159
256
|
}
|
|
160
257
|
|
|
161
|
-
|
|
258
|
+
const sweepHistory = (now: number, ttlByState: HistoryTtlByState) => {
|
|
259
|
+
for (const job of jobs.values()) {
|
|
260
|
+
if (!TERMINAL_STATES.has(job.state) || job.finishedAt === undefined) continue
|
|
261
|
+
// SAFETY: TERMINAL_STATES membership was checked above.
|
|
262
|
+
const state = job.state as TerminalState
|
|
263
|
+
const ttl = ttlByState[state]
|
|
264
|
+
const keepAge = keepPolicyFor(job.keep, state)?.ageMs
|
|
265
|
+
// The sweep honours min(per-row keep age, store ceiling) — a quiet job
|
|
266
|
+
// name is pruned on the timer, not only when its group is acked.
|
|
267
|
+
const effective = keepAge !== undefined && (ttl === undefined || keepAge < ttl) ? keepAge : ttl
|
|
268
|
+
if (effective !== undefined && job.finishedAt <= now - effective) {
|
|
269
|
+
jobs.delete(job.id)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Dead dedup entries: expired window, or a pointer at a vanished job.
|
|
273
|
+
for (const [key, entry] of dedupes) {
|
|
274
|
+
const alive = entry.expiresAt !== undefined
|
|
275
|
+
? entry.expiresAt > now
|
|
276
|
+
: jobs.has(entry.jobId) && !TERMINAL_STATES.has(jobs.get(entry.jobId)?.state ?? "completed")
|
|
277
|
+
if (!alive) dedupes.delete(key)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const service: Service = JobStore.of({
|
|
162
282
|
enqueue: (request: EnqueueRequest) =>
|
|
163
283
|
Effect.gen(function*() {
|
|
164
284
|
const now = yield* Clock.currentTimeMillis
|
|
285
|
+
if (request.id !== undefined && jobs.has(request.id)) {
|
|
286
|
+
return { id: request.id, duplicate: true }
|
|
287
|
+
}
|
|
288
|
+
// The dedup decision tree runs BEFORE id generation, so a
|
|
289
|
+
// deduplicated enqueue never consults the id generator.
|
|
290
|
+
if (request.dedupe !== undefined) {
|
|
291
|
+
const mapKey = dedupeMapKey(request.name, request.dedupe.key)
|
|
292
|
+
const entry = dedupes.get(mapKey)
|
|
293
|
+
if (entry !== undefined) {
|
|
294
|
+
const keyed = jobs.get(entry.jobId)
|
|
295
|
+
const windowLive = entry.expiresAt !== undefined && now < entry.expiresAt
|
|
296
|
+
// Latest-wins while the keyed job is still delayed.
|
|
297
|
+
if (request.dedupe.replace && keyed !== undefined && keyed.state === "delayed") {
|
|
298
|
+
keyed.payload = request.payload
|
|
299
|
+
keyed.metadata = request.metadata
|
|
300
|
+
keyed.priority = request.priority
|
|
301
|
+
keyed.attemptsMax = request.attemptsMax
|
|
302
|
+
keyed.backoff = request.backoff
|
|
303
|
+
keyed.keep = request.keep
|
|
304
|
+
keyed.timeoutMs = request.timeoutMs
|
|
305
|
+
keyed.runAt = now + Math.max(0, request.delayMs)
|
|
306
|
+
// A landed replace re-arms the ttl window.
|
|
307
|
+
if (request.dedupe.ttlMs !== undefined) {
|
|
308
|
+
entry.expiresAt = now + request.dedupe.ttlMs
|
|
309
|
+
}
|
|
310
|
+
signalWake(keyed.queue)
|
|
311
|
+
return { id: keyed.id, duplicate: true }
|
|
312
|
+
}
|
|
313
|
+
if (windowLive) {
|
|
314
|
+
if (request.dedupe.extend && request.dedupe.ttlMs !== undefined) {
|
|
315
|
+
entry.expiresAt = now + request.dedupe.ttlMs
|
|
316
|
+
}
|
|
317
|
+
return { id: entry.jobId, duplicate: true }
|
|
318
|
+
}
|
|
319
|
+
const pending = keyed !== undefined && !TERMINAL_STATES.has(keyed.state)
|
|
320
|
+
if (entry.expiresAt === undefined && pending) {
|
|
321
|
+
return { id: entry.jobId, duplicate: true }
|
|
322
|
+
}
|
|
323
|
+
// Dead entry (expired window / finished job): fall through and
|
|
324
|
+
// let the new job take over the key below.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
165
327
|
let id = request.id
|
|
166
328
|
if (id === undefined) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
329
|
+
const generate = options?.idGenerator
|
|
330
|
+
if (generate === undefined) {
|
|
331
|
+
// Store-assigned ids must never collide with user-supplied ones.
|
|
332
|
+
do {
|
|
333
|
+
id = JobId(`j-${++idCounter}`)
|
|
334
|
+
} while (jobs.has(id))
|
|
335
|
+
} else {
|
|
336
|
+
// A user generator gets a bounded number of collision retries; a
|
|
337
|
+
// healthy generator's entropy makes even one retry pathological.
|
|
338
|
+
for (let i = 0; i < 5 && id === undefined; i++) {
|
|
339
|
+
const raw = generate(request)
|
|
340
|
+
const candidate = JobId(Effect.isEffect(raw) ? yield* raw : raw)
|
|
341
|
+
if (!jobs.has(candidate)) {
|
|
342
|
+
id = candidate
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (id === undefined) {
|
|
346
|
+
return yield* new JobStoreError({
|
|
347
|
+
message: "enqueue failed: could not generate a unique job id"
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
}
|
|
173
351
|
}
|
|
174
352
|
jobs.set(id, {
|
|
175
353
|
id,
|
|
@@ -184,6 +362,9 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
184
362
|
stalledCount: 0,
|
|
185
363
|
backoff: request.backoff,
|
|
186
364
|
keep: request.keep,
|
|
365
|
+
timeoutMs: request.timeoutMs,
|
|
366
|
+
cancelRequested: false,
|
|
367
|
+
dedupeKey: request.dedupe?.key,
|
|
187
368
|
runAt: now + Math.max(0, request.delayMs),
|
|
188
369
|
enqueuedAt: now,
|
|
189
370
|
processedAt: undefined,
|
|
@@ -195,7 +376,13 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
195
376
|
lockToken: undefined,
|
|
196
377
|
lockExpiresAt: undefined
|
|
197
378
|
})
|
|
198
|
-
|
|
379
|
+
if (request.dedupe !== undefined) {
|
|
380
|
+
dedupes.set(dedupeMapKey(request.name, request.dedupe.key), {
|
|
381
|
+
jobId: id,
|
|
382
|
+
expiresAt: request.dedupe.ttlMs !== undefined ? now + request.dedupe.ttlMs : undefined
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
signalWake(request.queue)
|
|
199
386
|
return { id, duplicate: false }
|
|
200
387
|
}),
|
|
201
388
|
|
|
@@ -222,7 +409,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
222
409
|
}
|
|
223
410
|
}
|
|
224
411
|
}
|
|
225
|
-
if (best === undefined) {
|
|
412
|
+
if (best === undefined || paused.has(options.queue)) {
|
|
226
413
|
const empty: ClaimResult = { _tag: "Empty", nextRunAt, wakeToken: wakeVersion }
|
|
227
414
|
return empty
|
|
228
415
|
}
|
|
@@ -248,27 +435,47 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
248
435
|
job.attemptsMade += 1
|
|
249
436
|
switch (outcome._tag) {
|
|
250
437
|
case "Complete": {
|
|
438
|
+
job.cancelRequested = false
|
|
251
439
|
job.state = "completed"
|
|
252
440
|
job.exit = outcome.exit
|
|
253
441
|
job.finishedAt = now
|
|
254
442
|
recordAttempt(job, "completed", now, outcome.exit)
|
|
443
|
+
releaseDedupe(job, now)
|
|
255
444
|
applyKeep(job, now)
|
|
256
445
|
break
|
|
257
446
|
}
|
|
258
447
|
case "Fail": {
|
|
448
|
+
job.cancelRequested = false
|
|
259
449
|
job.state = "failed"
|
|
260
450
|
job.exit = outcome.exit
|
|
261
451
|
job.finishedAt = now
|
|
262
452
|
recordAttempt(job, "failed", now, outcome.exit)
|
|
453
|
+
releaseDedupe(job, now)
|
|
454
|
+
applyKeep(job, now)
|
|
455
|
+
break
|
|
456
|
+
}
|
|
457
|
+
case "Cancelled": {
|
|
458
|
+
job.cancelRequested = false
|
|
459
|
+
job.state = "cancelled"
|
|
460
|
+
job.finishedAt = now
|
|
461
|
+
recordAttempt(job, "cancelled", now, undefined)
|
|
462
|
+
releaseDedupe(job, now)
|
|
263
463
|
applyKeep(job, now)
|
|
264
464
|
break
|
|
265
465
|
}
|
|
266
466
|
case "Retry": {
|
|
467
|
+
if (job.cancelRequested) {
|
|
468
|
+
// A cancel raced a natural failure before the heartbeat could
|
|
469
|
+
// interrupt the run: cancellation wins over revival (mirrors
|
|
470
|
+
// release/recoverStalled).
|
|
471
|
+
markCancelled(job, now)
|
|
472
|
+
break
|
|
473
|
+
}
|
|
267
474
|
recordAttempt(job, "retried", now, outcome.exit)
|
|
268
475
|
job.runAt = now + Math.max(0, outcome.delayMs)
|
|
269
476
|
job.state = outcome.delayMs > 0 ? "delayed" : "waiting"
|
|
270
477
|
job.seq = ++seq
|
|
271
|
-
signalWake()
|
|
478
|
+
signalWake(job.queue)
|
|
272
479
|
break
|
|
273
480
|
}
|
|
274
481
|
}
|
|
@@ -276,6 +483,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
276
483
|
|
|
277
484
|
release: (id, token) =>
|
|
278
485
|
Effect.gen(function*() {
|
|
486
|
+
const now = yield* Clock.currentTimeMillis
|
|
279
487
|
const job = jobs.get(id)
|
|
280
488
|
if (job === undefined) {
|
|
281
489
|
return yield* new JobNotFoundError({ jobId: id })
|
|
@@ -283,28 +491,38 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
283
491
|
if (job.state !== "active" || job.lockToken !== token) {
|
|
284
492
|
return yield* new LockLostError({ jobId: id })
|
|
285
493
|
}
|
|
494
|
+
if (job.cancelRequested) {
|
|
495
|
+
// A cancel arrived while the worker was shutting down: honour it
|
|
496
|
+
// instead of reviving the job.
|
|
497
|
+
markCancelled(job, now)
|
|
498
|
+
return
|
|
499
|
+
}
|
|
286
500
|
clearLock(job)
|
|
287
501
|
job.state = "waiting"
|
|
288
|
-
signalWake()
|
|
502
|
+
signalWake(job.queue)
|
|
289
503
|
}),
|
|
290
504
|
|
|
291
505
|
extendLocks: (locks, durationMs) =>
|
|
292
506
|
Effect.gen(function*() {
|
|
293
507
|
const now = yield* Clock.currentTimeMillis
|
|
294
508
|
const lost: Array<JobId> = []
|
|
509
|
+
const cancelRequested: Array<JobId> = []
|
|
295
510
|
for (const lock of locks) {
|
|
296
511
|
const job = jobs.get(lock.id)
|
|
297
512
|
if (
|
|
298
|
-
job
|
|
299
|
-
job.state
|
|
300
|
-
job.lockToken
|
|
513
|
+
job === undefined ||
|
|
514
|
+
job.state !== "active" ||
|
|
515
|
+
job.lockToken !== lock.token
|
|
301
516
|
) {
|
|
302
|
-
job.lockExpiresAt = now + durationMs
|
|
303
|
-
} else {
|
|
304
517
|
lost.push(lock.id)
|
|
518
|
+
} else if (job.cancelRequested) {
|
|
519
|
+
cancelRequested.push(lock.id)
|
|
520
|
+
} else {
|
|
521
|
+
job.lockExpiresAt = now + durationMs
|
|
305
522
|
}
|
|
306
523
|
}
|
|
307
|
-
|
|
524
|
+
const result: ExtendLocksResult = { lost, cancelRequested }
|
|
525
|
+
return result
|
|
308
526
|
}),
|
|
309
527
|
|
|
310
528
|
recoverStalled: (options) =>
|
|
@@ -319,6 +537,11 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
319
537
|
) {
|
|
320
538
|
continue
|
|
321
539
|
}
|
|
540
|
+
if (job.cancelRequested) {
|
|
541
|
+
// The owning worker died before honouring the cancel: finish it.
|
|
542
|
+
markCancelled(job, now)
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
322
545
|
clearLock(job)
|
|
323
546
|
job.stalledCount += 1
|
|
324
547
|
recordAttempt(job, "stalled", now, undefined)
|
|
@@ -326,6 +549,7 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
326
549
|
job.state = "failed"
|
|
327
550
|
job.finishedAt = now
|
|
328
551
|
job.failedReason = "job stalled more than allowable limit"
|
|
552
|
+
releaseDedupe(job, now)
|
|
329
553
|
recovered.push({ id: job.id, failed: true })
|
|
330
554
|
} else {
|
|
331
555
|
job.state = "waiting"
|
|
@@ -338,10 +562,14 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
338
562
|
return recovered
|
|
339
563
|
}),
|
|
340
564
|
|
|
341
|
-
awaitWake: (
|
|
565
|
+
awaitWake: (queues, wakeToken) =>
|
|
342
566
|
Effect.suspend(() => {
|
|
343
|
-
if (
|
|
344
|
-
|
|
567
|
+
if (queues.some((queue) => lastWakeFor(queue) > wakeToken)) return Effect.void
|
|
568
|
+
const waiter: Waiter = { queues: new Set(queues), deferred: Deferred.makeUnsafe<void>() }
|
|
569
|
+
waiters.add(waiter)
|
|
570
|
+
return Deferred.await(waiter.deferred).pipe(
|
|
571
|
+
Effect.ensuring(Effect.sync(() => waiters.delete(waiter)))
|
|
572
|
+
)
|
|
345
573
|
}),
|
|
346
574
|
|
|
347
575
|
getJob: (id) =>
|
|
@@ -414,13 +642,109 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
414
642
|
job.state = "waiting"
|
|
415
643
|
job.attemptsMade = 0
|
|
416
644
|
job.stalledCount = 0
|
|
645
|
+
job.cancelRequested = false
|
|
417
646
|
job.exit = undefined
|
|
418
647
|
job.failedReason = undefined
|
|
419
648
|
job.finishedAt = undefined
|
|
420
649
|
job.processedAt = undefined
|
|
421
650
|
job.runAt = now
|
|
422
651
|
job.seq = ++seq
|
|
423
|
-
signalWake()
|
|
652
|
+
signalWake(job.queue)
|
|
653
|
+
}),
|
|
654
|
+
|
|
655
|
+
cancel: (id) =>
|
|
656
|
+
Effect.gen(function*() {
|
|
657
|
+
const now = yield* Clock.currentTimeMillis
|
|
658
|
+
const job = jobs.get(id)
|
|
659
|
+
if (job === undefined) {
|
|
660
|
+
return yield* new JobNotFoundError({ jobId: id })
|
|
661
|
+
}
|
|
662
|
+
switch (job.state) {
|
|
663
|
+
case "waiting":
|
|
664
|
+
case "delayed": {
|
|
665
|
+
markCancelled(job, now)
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
case "active": {
|
|
669
|
+
job.cancelRequested = true
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
default: {
|
|
673
|
+
return yield* new JobNotCancellableError({ jobId: id, state: job.state })
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}),
|
|
677
|
+
|
|
678
|
+
promote: (id) =>
|
|
679
|
+
Effect.gen(function*() {
|
|
680
|
+
const now = yield* Clock.currentTimeMillis
|
|
681
|
+
const job = jobs.get(id)
|
|
682
|
+
if (job === undefined) {
|
|
683
|
+
return yield* new JobNotFoundError({ jobId: id })
|
|
684
|
+
}
|
|
685
|
+
if (job.state !== "delayed") {
|
|
686
|
+
return yield* new JobNotPromotableError({ jobId: id, state: job.state })
|
|
687
|
+
}
|
|
688
|
+
job.state = "waiting"
|
|
689
|
+
job.runAt = now
|
|
690
|
+
signalWake(job.queue)
|
|
691
|
+
}),
|
|
692
|
+
|
|
693
|
+
pause: (queue) =>
|
|
694
|
+
Effect.sync(() => {
|
|
695
|
+
paused.add(queue)
|
|
696
|
+
}),
|
|
697
|
+
|
|
698
|
+
resume: (queue) =>
|
|
699
|
+
Effect.sync(() => {
|
|
700
|
+
if (paused.delete(queue)) {
|
|
701
|
+
signalWake(queue)
|
|
702
|
+
}
|
|
703
|
+
}),
|
|
704
|
+
|
|
705
|
+
pausedQueues: () => Effect.sync(() => Array.from(paused)),
|
|
706
|
+
|
|
707
|
+
upsertSchedule: (schedule) =>
|
|
708
|
+
Effect.sync(() => {
|
|
709
|
+
// An unchanged cadence keeps its next occurrence: deploy-time
|
|
710
|
+
// re-registration must not re-anchor `every` grids or drop a pending
|
|
711
|
+
// catch-up run. A changed cadence takes the caller's fresh nextRunAt.
|
|
712
|
+
const existing = schedules.get(schedule.key)
|
|
713
|
+
const sameCadence = existing !== undefined &&
|
|
714
|
+
existing.cron === schedule.cron &&
|
|
715
|
+
existing.tz === schedule.tz &&
|
|
716
|
+
existing.everyMs === schedule.everyMs
|
|
717
|
+
schedules.set(
|
|
718
|
+
schedule.key,
|
|
719
|
+
sameCadence ? { ...schedule, nextRunAt: existing.nextRunAt } : schedule
|
|
720
|
+
)
|
|
721
|
+
signalWake(schedule.queue)
|
|
722
|
+
}),
|
|
723
|
+
|
|
724
|
+
removeSchedule: (key) => Effect.sync(() => schedules.delete(key)),
|
|
725
|
+
|
|
726
|
+
listSchedules: (options) =>
|
|
727
|
+
Effect.sync(() =>
|
|
728
|
+
Array.from(schedules.values()).filter((schedule) =>
|
|
729
|
+
(options?.jobName === undefined || schedule.jobName === options.jobName) &&
|
|
730
|
+
(options?.queue === undefined || schedule.queue === options.queue)
|
|
731
|
+
)
|
|
732
|
+
),
|
|
733
|
+
|
|
734
|
+
dueSchedules: () =>
|
|
735
|
+
Effect.gen(function*() {
|
|
736
|
+
const now = yield* Clock.currentTimeMillis
|
|
737
|
+
return Array.from(schedules.values())
|
|
738
|
+
.filter((schedule) => schedule.nextRunAt <= now)
|
|
739
|
+
.toSorted((a, b) => a.nextRunAt - b.nextRunAt)
|
|
740
|
+
}),
|
|
741
|
+
|
|
742
|
+
advanceSchedule: (key, expectedRunAt, nextRunAt) =>
|
|
743
|
+
Effect.sync(() => {
|
|
744
|
+
const schedule = schedules.get(key)
|
|
745
|
+
if (schedule !== undefined && schedule.nextRunAt === expectedRunAt) {
|
|
746
|
+
schedules.set(key, { ...schedule, nextRunAt })
|
|
747
|
+
}
|
|
424
748
|
}),
|
|
425
749
|
|
|
426
750
|
counts: (queue) =>
|
|
@@ -430,7 +754,8 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
430
754
|
delayed: 0,
|
|
431
755
|
active: 0,
|
|
432
756
|
completed: 0,
|
|
433
|
-
failed: 0
|
|
757
|
+
failed: 0,
|
|
758
|
+
cancelled: 0
|
|
434
759
|
} satisfies Record<JobState, number>
|
|
435
760
|
for (const job of jobs.values()) {
|
|
436
761
|
if (queue !== undefined && job.queue !== queue) continue
|
|
@@ -447,15 +772,76 @@ export const make: Effect.Effect<Service> = Effect.sync(() => {
|
|
|
447
772
|
return true
|
|
448
773
|
})
|
|
449
774
|
})
|
|
450
|
-
|
|
775
|
+
|
|
776
|
+
return { service, sweepHistory }
|
|
777
|
+
}
|
|
451
778
|
|
|
452
779
|
/**
|
|
453
|
-
*
|
|
454
|
-
|
|
780
|
+
* @since 0.2.0
|
|
781
|
+
*/
|
|
782
|
+
export interface MemoryJobStoreOptions {
|
|
783
|
+
/**
|
|
784
|
+
* Store-level retention ceiling: terminal records older than this are
|
|
785
|
+
* removed by a periodic sweep — one duration for all terminal states or a
|
|
786
|
+
* per-state split (`{ completed: "1 day", failed: "30 days" }`). The sweep
|
|
787
|
+
* also honours stricter per-job `keep.age` rules.
|
|
788
|
+
*/
|
|
789
|
+
readonly historyTtl?: HistoryTtlInput | undefined
|
|
790
|
+
/** Sweep cadence (default 1 minute). */
|
|
791
|
+
readonly historySweepInterval?: Duration.Input | undefined
|
|
792
|
+
/**
|
|
793
|
+
* Generator for store-assigned job ids (e.g. `() => \`job_${ulid()}\``).
|
|
794
|
+
* Default: a `j-<n>` counter. See `JobStore.IdGenerator`.
|
|
795
|
+
*/
|
|
796
|
+
readonly idGenerator?: IdGenerator | undefined
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Build a fresh in-memory `JobStore` implementation (no history sweeper —
|
|
801
|
+
* use `makeWith` for `historyTtl` support).
|
|
455
802
|
*
|
|
456
803
|
* @since 0.1.0
|
|
457
804
|
*/
|
|
458
|
-
export const
|
|
805
|
+
export const make: Effect.Effect<Service> = Effect.sync(() => makeStoreUnsafe().service)
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Build a fresh in-memory `JobStore` with options; the history sweeper (when
|
|
809
|
+
* configured) lives in the surrounding `Scope`.
|
|
810
|
+
*
|
|
811
|
+
* @since 0.2.0
|
|
812
|
+
*/
|
|
813
|
+
export const makeWith = (
|
|
814
|
+
options?: MemoryJobStoreOptions | undefined
|
|
815
|
+
): Effect.Effect<Service, never, Scope.Scope> =>
|
|
816
|
+
Effect.gen(function*() {
|
|
817
|
+
const { service, sweepHistory } = makeStoreUnsafe(options)
|
|
818
|
+
if (options?.historyTtl !== undefined) {
|
|
819
|
+
const ttlByState = normalizeHistoryTtl(options.historyTtl)
|
|
820
|
+
const intervalMs = Duration.toMillis(options.historySweepInterval ?? "1 minute")
|
|
821
|
+
yield* Effect.gen(function*() {
|
|
822
|
+
yield* Effect.sleep(intervalMs)
|
|
823
|
+
const now = yield* Clock.currentTimeMillis
|
|
824
|
+
sweepHistory(now, ttlByState)
|
|
825
|
+
}).pipe(Effect.forever, Effect.forkScoped)
|
|
826
|
+
}
|
|
827
|
+
return service
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* A fresh in-memory `JobStore` layer.
|
|
832
|
+
*
|
|
833
|
+
* @since 0.1.0
|
|
834
|
+
*/
|
|
835
|
+
export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, makeWith())
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* An in-memory layer with options (e.g. `historyTtl`).
|
|
839
|
+
*
|
|
840
|
+
* @since 0.2.0
|
|
841
|
+
*/
|
|
842
|
+
export const layerWith = (
|
|
843
|
+
options?: MemoryJobStoreOptions | undefined
|
|
844
|
+
): Layer.Layer<JobStore> => Layer.effect(JobStore, makeWith(options))
|
|
459
845
|
|
|
460
846
|
/**
|
|
461
847
|
* An in-memory layer for a specific store key.
|
|
@@ -463,5 +849,6 @@ export const layer: Layer.Layer<JobStore> = Layer.effect(JobStore, make)
|
|
|
463
849
|
* @since 0.1.0
|
|
464
850
|
*/
|
|
465
851
|
export const layerFor = <Id>(
|
|
466
|
-
store: Context.Key<Id, Service
|
|
467
|
-
|
|
852
|
+
store: Context.Key<Id, Service>,
|
|
853
|
+
options?: MemoryJobStoreOptions | undefined
|
|
854
|
+
): Layer.Layer<Id> => Layer.effect(store, makeWith(options))
|