effect-mq 0.6.0 → 0.7.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/dist/JobStore.d.ts +41 -3
- package/dist/JobStore.d.ts.map +1 -1
- package/dist/JobStore.js +10 -0
- package/dist/JobStore.js.map +1 -1
- package/dist/MemoryJobStore.d.ts.map +1 -1
- package/dist/MemoryJobStore.js +27 -14
- package/dist/MemoryJobStore.js.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.d.ts.map +1 -1
- package/dist/drizzle-postgres/DrizzleJobStore.js +25 -3
- package/dist/drizzle-postgres/DrizzleJobStore.js.map +1 -1
- package/dist/redis/RedisJobStore.d.ts +53 -0
- package/dist/redis/RedisJobStore.d.ts.map +1 -1
- package/dist/redis/RedisJobStore.js +188 -37
- package/dist/redis/RedisJobStore.js.map +1 -1
- package/dist/redis/scripts.d.ts +101 -30
- package/dist/redis/scripts.d.ts.map +1 -1
- package/dist/redis/scripts.js +204 -55
- package/dist/redis/scripts.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +127 -11
- package/dist/testing/conformance.js.map +1 -1
- package/package.json +1 -1
- package/src/JobStore.ts +38 -3
- package/src/MemoryJobStore.ts +26 -18
- package/src/drizzle-postgres/DrizzleJobStore.ts +27 -3
- package/src/redis/RedisJobStore.ts +256 -36
- package/src/redis/scripts.ts +232 -59
- package/src/testing/conformance.ts +143 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "effect-mq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Effect-native background jobs: schema-first definitions, storage-agnostic queue core, worker runtime, and a Postgres store through drizzle.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Adam Rankin",
|
package/src/JobStore.ts
CHANGED
|
@@ -594,9 +594,18 @@ export type AckOutcome =
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
/**
|
|
597
|
-
* Filters and pagination for `list`.
|
|
598
|
-
* (`enqueuedAt` desc, then id desc); pass the returned
|
|
599
|
-
* the next
|
|
597
|
+
* Filters, ordering, and pagination for `list`. The default order is
|
|
598
|
+
* newest-first (`enqueuedAt` desc, then id desc); pass the returned
|
|
599
|
+
* `cursor` back — with the SAME options that produced it — to get the next
|
|
600
|
+
* page.
|
|
601
|
+
*
|
|
602
|
+
* Ordering is not free on every storage layout, so the contract defines a
|
|
603
|
+
* REQUIRED (filter, orderBy) surface every driver serves
|
|
604
|
+
* (conformance-pinned): `enqueuedAt` with any filters; `runAt` for
|
|
605
|
+
* `states: ["delayed"]` within a `queue`; `finishedAt` for terminal
|
|
606
|
+
* `states`, with or without `name`/`queue`. Beyond that surface a driver
|
|
607
|
+
* either serves the query or dies with `ListOrderUnsupportedError` — never
|
|
608
|
+
* a silent full scan. Memory and Postgres serve every combination.
|
|
600
609
|
*
|
|
601
610
|
* @since 0.1.0
|
|
602
611
|
*/
|
|
@@ -606,11 +615,37 @@ export interface ListOptions {
|
|
|
606
615
|
readonly states?: ReadonlyArray<JobState> | undefined
|
|
607
616
|
/** Every entry must match the record's metadata exactly (AND semantics). */
|
|
608
617
|
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
618
|
+
/**
|
|
619
|
+
* The field to order by (default `enqueuedAt`). Jobs missing the field
|
|
620
|
+
* (`finishedAt` on non-terminal rows) sort as 0.
|
|
621
|
+
*
|
|
622
|
+
* @since 0.7.0
|
|
623
|
+
*/
|
|
624
|
+
readonly orderBy?: "enqueuedAt" | "runAt" | "finishedAt" | undefined
|
|
625
|
+
/**
|
|
626
|
+
* Direction (default `desc`). The id tiebreak follows the direction.
|
|
627
|
+
*
|
|
628
|
+
* @since 0.7.0
|
|
629
|
+
*/
|
|
630
|
+
readonly order?: "asc" | "desc" | undefined
|
|
609
631
|
readonly cursor?: string | undefined
|
|
610
632
|
/** Page size; default 50. */
|
|
611
633
|
readonly limit?: number | undefined
|
|
612
634
|
}
|
|
613
635
|
|
|
636
|
+
/**
|
|
637
|
+
* A driver received a (filter, `orderBy`) combination outside the surface
|
|
638
|
+
* it can serve without a full scan (see `ListOptions`). Delivered as a
|
|
639
|
+
* defect: the query contradicts the driver's documented matrix, which is a
|
|
640
|
+
* programming mistake, not a runtime condition to recover from.
|
|
641
|
+
*
|
|
642
|
+
* @since 0.7.0
|
|
643
|
+
*/
|
|
644
|
+
export class ListOrderUnsupportedError extends Data.TaggedError("ListOrderUnsupportedError")<{
|
|
645
|
+
readonly orderBy: string
|
|
646
|
+
readonly message: string
|
|
647
|
+
}> {}
|
|
648
|
+
|
|
614
649
|
/**
|
|
615
650
|
* @since 0.1.0
|
|
616
651
|
*/
|
package/src/MemoryJobStore.ts
CHANGED
|
@@ -795,7 +795,14 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
795
795
|
Effect.sync(() => {
|
|
796
796
|
const limit = Math.max(1, options.limit ?? 50)
|
|
797
797
|
const states = options.states === undefined ? undefined : new Set(options.states)
|
|
798
|
-
|
|
798
|
+
const orderBy = options.orderBy ?? "enqueuedAt"
|
|
799
|
+
const descending = (options.order ?? "desc") === "desc"
|
|
800
|
+
// Jobs missing the field (finishedAt on non-terminal rows) sort as 0.
|
|
801
|
+
const orderValue = (job: MemJob): number =>
|
|
802
|
+
orderBy === "enqueuedAt" ? job.enqueuedAt : orderBy === "runAt" ? job.runAt : job.finishedAt ?? 0
|
|
803
|
+
const compareIds = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
804
|
+
// Stable across retries: the id tiebreak follows the direction, and
|
|
805
|
+
// the cursor excludes everything at or before its (value, id).
|
|
799
806
|
let cursor: { readonly at: number; readonly id: string } | undefined
|
|
800
807
|
if (options.cursor !== undefined) {
|
|
801
808
|
const split = options.cursor.indexOf(":")
|
|
@@ -811,26 +818,27 @@ const makeStoreUnsafe = (options?: MemoryJobStoreOptions | undefined): MemorySto
|
|
|
811
818
|
(states === undefined || states.has(job.state)) &&
|
|
812
819
|
(options.metadata === undefined || metadataMatches(job.metadata, options.metadata))
|
|
813
820
|
)
|
|
814
|
-
.toSorted((a, b) =>
|
|
815
|
-
|
|
816
|
-
? b
|
|
817
|
-
:
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
821
|
+
.toSorted((a, b) => {
|
|
822
|
+
const byValue = descending
|
|
823
|
+
? orderValue(b) - orderValue(a)
|
|
824
|
+
: orderValue(a) - orderValue(b)
|
|
825
|
+
if (byValue !== 0) return byValue
|
|
826
|
+
return descending ? compareIds(b.id, a.id) : compareIds(a.id, b.id)
|
|
827
|
+
})
|
|
828
|
+
.filter((job) => {
|
|
829
|
+
if (cursor === undefined) return true
|
|
830
|
+
const value = orderValue(job)
|
|
831
|
+
if (descending) {
|
|
832
|
+
return value < cursor.at || (value === cursor.at && job.id < cursor.id)
|
|
833
|
+
}
|
|
834
|
+
return value > cursor.at || (value === cursor.at && job.id > cursor.id)
|
|
835
|
+
})
|
|
828
836
|
const items = matches.slice(0, limit).map(snapshot)
|
|
829
|
-
const
|
|
837
|
+
const lastJob = matches[Math.min(limit, matches.length) - 1]
|
|
830
838
|
const result: ListResult = {
|
|
831
839
|
items,
|
|
832
|
-
cursor: matches.length > limit &&
|
|
833
|
-
? `${
|
|
840
|
+
cursor: matches.length > limit && lastJob !== undefined
|
|
841
|
+
? `${orderValue(lastJob)}:${lastJob.id}`
|
|
834
842
|
: undefined
|
|
835
843
|
}
|
|
836
844
|
return result
|
|
@@ -1585,12 +1585,30 @@ export const make = (
|
|
|
1585
1585
|
if (listOptions.metadata !== undefined && Object.keys(listOptions.metadata).length > 0) {
|
|
1586
1586
|
conditions.push(sql`${jobs.metadata} @> ${JSON.stringify(listOptions.metadata)}::jsonb`)
|
|
1587
1587
|
}
|
|
1588
|
+
const orderBy = listOptions.orderBy ?? "enqueuedAt"
|
|
1589
|
+
const descending = (listOptions.order ?? "desc") === "desc"
|
|
1590
|
+
// Order on the exact value the record reports: enqueued_at/run_at
|
|
1591
|
+
// are NOT NULL columns; finished_at is absent on non-terminal rows
|
|
1592
|
+
// and sorts as 0 — COALESCE to the epoch, which is what the
|
|
1593
|
+
// `<orderValueMillis>:<id>` cursor round-trips through `new
|
|
1594
|
+
// Date(0)`. All stored timestamps were bound from the Clock at
|
|
1595
|
+
// millisecond precision, so cursor equality comparisons are exact.
|
|
1596
|
+
const orderExpr = orderBy === "enqueuedAt"
|
|
1597
|
+
? sql`${jobs.enqueuedAt}`
|
|
1598
|
+
: orderBy === "runAt"
|
|
1599
|
+
? sql`${jobs.runAt}`
|
|
1600
|
+
: sql`COALESCE(${jobs.finishedAt}, 'epoch'::timestamptz)`
|
|
1601
|
+
const direction = descending ? sql`DESC` : sql`ASC`
|
|
1588
1602
|
if (listOptions.cursor !== undefined) {
|
|
1603
|
+
// Exclusive keyset: `<orderValueMillis>:<id>`, strictly past the
|
|
1604
|
+
// cursor row in the requested direction (id tiebreak included).
|
|
1589
1605
|
const split = listOptions.cursor.indexOf(":")
|
|
1590
1606
|
const cursorAt = new Date(Number(listOptions.cursor.slice(0, split)))
|
|
1591
1607
|
const cursorId = listOptions.cursor.slice(split + 1)
|
|
1592
1608
|
conditions.push(
|
|
1593
|
-
|
|
1609
|
+
descending
|
|
1610
|
+
? sql`(${orderExpr}, ${jobs.id}) < (${cursorAt}, ${cursorId})`
|
|
1611
|
+
: sql`(${orderExpr}, ${jobs.id}) > (${cursorAt}, ${cursorId})`
|
|
1594
1612
|
)
|
|
1595
1613
|
}
|
|
1596
1614
|
const rows = rowsOf(yield* db.execute<JobRow>(sql`
|
|
@@ -1610,7 +1628,7 @@ export const make = (
|
|
|
1610
1628
|
${jobs.exit} AS "exit", ${jobs.failedReason} AS "failedReason"
|
|
1611
1629
|
FROM ${jobs}
|
|
1612
1630
|
WHERE ${sql.join(conditions, sql` AND `)}
|
|
1613
|
-
ORDER BY ${
|
|
1631
|
+
ORDER BY ${orderExpr} ${direction}, ${jobs.id} ${direction}
|
|
1614
1632
|
LIMIT ${limit + 1}
|
|
1615
1633
|
`).pipe(Effect.mapError(storeError("list failed"))))
|
|
1616
1634
|
const items = rows.slice(0, limit).map(toRecord)
|
|
@@ -1618,7 +1636,13 @@ export const make = (
|
|
|
1618
1636
|
return {
|
|
1619
1637
|
items,
|
|
1620
1638
|
cursor: rows.length > limit && last !== undefined
|
|
1621
|
-
? `${
|
|
1639
|
+
? `${
|
|
1640
|
+
orderBy === "enqueuedAt"
|
|
1641
|
+
? last.enqueuedAt
|
|
1642
|
+
: orderBy === "runAt"
|
|
1643
|
+
? last.runAt
|
|
1644
|
+
: last.finishedAt ?? 0
|
|
1645
|
+
}:${last.id}`
|
|
1622
1646
|
: undefined
|
|
1623
1647
|
}
|
|
1624
1648
|
}),
|
|
@@ -12,7 +12,20 @@
|
|
|
12
12
|
*
|
|
13
13
|
* @since 0.2.0
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
Clock,
|
|
17
|
+
type Context,
|
|
18
|
+
Data,
|
|
19
|
+
Deferred,
|
|
20
|
+
Duration,
|
|
21
|
+
Effect,
|
|
22
|
+
Exit,
|
|
23
|
+
Layer,
|
|
24
|
+
Option,
|
|
25
|
+
Queue,
|
|
26
|
+
Schedule,
|
|
27
|
+
type Scope
|
|
28
|
+
} from "effect"
|
|
16
29
|
import { Redis } from "effect/unstable/persistence"
|
|
17
30
|
import * as JobStore from "../JobStore.ts"
|
|
18
31
|
import * as scripts from "./scripts.ts"
|
|
@@ -37,6 +50,65 @@ export interface RedisJobStoreOptions {
|
|
|
37
50
|
* Default: `j-<n>` from the store's counter. See `JobStore.IdGenerator`.
|
|
38
51
|
*/
|
|
39
52
|
readonly idGenerator?: JobStore.IdGenerator | undefined
|
|
53
|
+
/**
|
|
54
|
+
* The optional list indexes: `p:byname:<name>` and `p:byqueue:<queue>`,
|
|
55
|
+
* both scored by `enqueuedAt`, serving `list({ name })` / `list({ queue })`
|
|
56
|
+
* without a full scan. Default: all on. `false` disables both; a partial
|
|
57
|
+
* object disables one (`{ name: false }`). Disable an index only when you
|
|
58
|
+
* never list that way — a `list` that ROUTES to a disabled index dies with
|
|
59
|
+
* `ListIndexDisabledError` (a query servable from another structure, e.g.
|
|
60
|
+
* `name` + terminal `states` ordered by `finishedAt`, never touches it).
|
|
61
|
+
*
|
|
62
|
+
* This setting is a PER-PREFIX invariant: every store instance sharing a
|
|
63
|
+
* key prefix must agree on it. Index writes happen at insert time in the
|
|
64
|
+
* writing process, so a store with an index off inserts rows that index
|
|
65
|
+
* never sees — enabled readers on the same prefix silently miss those rows
|
|
66
|
+
* (the `p:index:<kind>:ready` marker churn between mixed stores is a
|
|
67
|
+
* symptom of the misconfiguration, not a safety net against it).
|
|
68
|
+
*
|
|
69
|
+
* Init reconciles each index one-shot. Enabled with no `ready` marker: a
|
|
70
|
+
* full driver-paged ZSCAN of `p:all` rebuilds it, then stamps the marker
|
|
71
|
+
* with the rebuild's start time. Enabled with a marker: rows enqueued since
|
|
72
|
+
* the marker minus a 60s margin are (re-)indexed — so a rolling deploy
|
|
73
|
+
* whose old, index-less writers kept inserting after the marker landed is
|
|
74
|
+
* healed by the last new-code boot — and the marker is re-stamped.
|
|
75
|
+
* Disabled: only the marker is deleted (a future re-enable then does a full
|
|
76
|
+
* rebuild instead of trusting stale zsets). The zsets themselves are NEVER
|
|
77
|
+
* deleted at init — this store cannot know whether an enabled sibling is
|
|
78
|
+
* serving reads from them. After disabling everywhere, reclaim the memory
|
|
79
|
+
* manually, e.g.:
|
|
80
|
+
* `redis-cli --scan --pattern '<prefix>:byname:*' | xargs redis-cli del`.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.7.0
|
|
83
|
+
*/
|
|
84
|
+
readonly indexes?: { readonly name?: boolean | undefined; readonly queue?: boolean | undefined } | false | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A `list` query routed to a list index this store is configured not to
|
|
89
|
+
* maintain (`RedisJobStoreOptions.indexes`). Delivered as a defect, not a
|
|
90
|
+
* typed failure: the configuration said "we never list this way", so the
|
|
91
|
+
* query contradicting it is a programming mistake, matching the library's
|
|
92
|
+
* die-on-config-mistake idiom.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.7.0
|
|
95
|
+
*/
|
|
96
|
+
export class ListIndexDisabledError extends Data.TaggedError("ListIndexDisabledError")<{
|
|
97
|
+
readonly index: "name" | "queue"
|
|
98
|
+
readonly message: string
|
|
99
|
+
}> {}
|
|
100
|
+
|
|
101
|
+
const TERMINAL_STATES: ReadonlySet<JobStore.JobState> = new Set(["completed", "failed", "cancelled"])
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `list` predicates the routed structure does NOT pin, applied per row
|
|
105
|
+
* inside the list script. Field names are part of the script's contract.
|
|
106
|
+
*/
|
|
107
|
+
interface ResidualFilters {
|
|
108
|
+
readonly queue?: JobStore.QueueName | undefined
|
|
109
|
+
readonly name?: string | undefined
|
|
110
|
+
readonly states?: ReadonlyArray<JobStore.JobState> | undefined
|
|
111
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined
|
|
40
112
|
}
|
|
41
113
|
|
|
42
114
|
const storeError = (message: string) => (cause: unknown) => new JobStore.JobStoreError({ message, cause })
|
|
@@ -202,34 +274,104 @@ export const make = (
|
|
|
202
274
|
const redis = yield* Redis.Redis
|
|
203
275
|
const prefix = options?.prefix ?? "effect-mq"
|
|
204
276
|
const wakeChannel = `${prefix}:wake`
|
|
277
|
+
const indexOptions = options?.indexes
|
|
278
|
+
const indexes: scripts.IndexConfig = indexOptions === false
|
|
279
|
+
? { name: false, queue: false }
|
|
280
|
+
: { name: indexOptions?.name ?? true, queue: indexOptions?.queue ?? true }
|
|
281
|
+
const HELPERS = scripts.helpers(indexes)
|
|
282
|
+
|
|
283
|
+
const evalEnqueue = redis.eval(scripts.enqueue(HELPERS))
|
|
284
|
+
const evalClaim = redis.eval(scripts.claim(HELPERS))
|
|
285
|
+
const evalAck = redis.eval(scripts.ack(HELPERS))
|
|
286
|
+
const evalRelease = redis.eval(scripts.release(HELPERS))
|
|
287
|
+
const evalExtendLocks = redis.eval(scripts.extendLocks(HELPERS))
|
|
288
|
+
const evalRecoverStalled = redis.eval(scripts.recoverStalled(HELPERS))
|
|
289
|
+
const evalGetJob = redis.eval(scripts.getJob(HELPERS))
|
|
290
|
+
const evalList = redis.eval(scripts.list(HELPERS))
|
|
291
|
+
const evalIndexMembers = redis.eval(scripts.indexMembers(HELPERS))
|
|
292
|
+
const evalIndexTailPage = redis.eval(scripts.indexTailPage(HELPERS))
|
|
293
|
+
const evalCounts = redis.eval(scripts.counts(HELPERS))
|
|
294
|
+
const evalRemove = redis.eval(scripts.remove(HELPERS))
|
|
295
|
+
const evalRetry = redis.eval(scripts.retry(HELPERS))
|
|
296
|
+
const evalCancel = redis.eval(scripts.cancel(HELPERS))
|
|
297
|
+
const evalPromote = redis.eval(scripts.promote(HELPERS))
|
|
298
|
+
const evalUpsertSchedule = redis.eval(scripts.upsertSchedule(HELPERS))
|
|
299
|
+
const evalRemoveSchedule = redis.eval(scripts.removeSchedule(HELPERS))
|
|
300
|
+
const evalListSchedules = redis.eval(scripts.listSchedules(HELPERS))
|
|
301
|
+
const evalDueSchedules = redis.eval(scripts.dueSchedules(HELPERS))
|
|
302
|
+
const evalAdvanceSchedule = redis.eval(scripts.advanceSchedule(HELPERS))
|
|
303
|
+
const evalTickSchedule = redis.eval(scripts.tickSchedule(HELPERS))
|
|
304
|
+
const evalEnqueueMany = redis.eval(scripts.enqueueMany(HELPERS))
|
|
305
|
+
const evalSweepState = redis.eval(scripts.sweepState(HELPERS))
|
|
306
|
+
const evalSweepDedupes = redis.eval(scripts.sweepDedupes(HELPERS))
|
|
307
|
+
const evalFanOut = redis.eval(scripts.fanOut(HELPERS))
|
|
308
|
+
const evalRecordChildResults = redis.eval(scripts.recordChildResults(HELPERS))
|
|
309
|
+
const evalListChildResults = redis.eval(scripts.listChildResults(HELPERS))
|
|
310
|
+
const evalFlowSweepWork = redis.eval(scripts.flowSweepWork(HELPERS))
|
|
311
|
+
const evalMarkChildrenCascaded = redis.eval(scripts.markChildrenCascaded(HELPERS))
|
|
205
312
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
313
|
+
// List-index reconcile, once per boot per index. All work is driver-paged
|
|
314
|
+
// — never one giant Lua call — so the single-threaded server is never
|
|
315
|
+
// held. There is no build lock: concurrent boots duplicate idempotent
|
|
316
|
+
// ZADDs, a crash before the marker stamp makes the next boot redo the
|
|
317
|
+
// work, and rows inserted meanwhile are indexed live by insertJobRow.
|
|
318
|
+
//
|
|
319
|
+
// - enabled, no marker: full rebuild via ZSCAN over `all` (cursor-based
|
|
320
|
+
// and linear — immune to score ties and rank shifts; every member
|
|
321
|
+
// present for the whole scan is guaranteed returned). Rows deleted
|
|
322
|
+
// mid-scan leave at most stale members the read path self-heals.
|
|
323
|
+
// - enabled, marker present: heal the tail. Index writes are per-process,
|
|
324
|
+
// so writers without them (an older version mid-rolling-deploy, or a
|
|
325
|
+
// misconfigured indexes-off store) may have inserted unindexed rows
|
|
326
|
+
// AFTER the marker landed. Re-indexing everything enqueued since the
|
|
327
|
+
// marker minus a 60s margin closes that window: the last enabled boot
|
|
328
|
+
// after such writers stop covers everything they wrote before it.
|
|
329
|
+
// - disabled: delete ONLY the marker (a later re-enable must not trust
|
|
330
|
+
// stale zsets). The zsets stay — an enabled sibling may be reading
|
|
331
|
+
// them, and this store cannot know.
|
|
332
|
+
//
|
|
333
|
+
// Both paths re-stamp the marker with this boot's start time. Init-time
|
|
334
|
+
// infra failures die — the store never starts half-configured.
|
|
335
|
+
yield* Effect.gen(function*() {
|
|
336
|
+
const bootAt = yield* Clock.currentTimeMillis
|
|
337
|
+
for (const kind of ["name", "queue"] as const) {
|
|
338
|
+
const marker = `${prefix}:index:${kind}:ready`
|
|
339
|
+
if (!indexes[kind]) {
|
|
340
|
+
yield* redis.send("DEL", marker)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
// SAFETY: GET always replies with a bulk string or null.
|
|
344
|
+
const stamped = (yield* redis.send("GET", marker)) as string | null
|
|
345
|
+
const markerAt = stamped === null || stamped === "" ? Number.NaN : Number(stamped)
|
|
346
|
+
if (Number.isNaN(markerAt)) {
|
|
347
|
+
let cursor = "0"
|
|
348
|
+
do {
|
|
349
|
+
const reply = yield* redis.send("ZSCAN", `${prefix}:all`, cursor, "COUNT", "500")
|
|
350
|
+
// SAFETY: ZSCAN always replies [nextCursor, member/score pairs].
|
|
351
|
+
const [next, flat] = reply as [string, ReadonlyArray<string>]
|
|
352
|
+
cursor = next
|
|
353
|
+
const ids: Array<string> = []
|
|
354
|
+
for (let i = 0; i < flat.length; i += 2) {
|
|
355
|
+
const id = flat[i]
|
|
356
|
+
if (id !== undefined) ids.push(id)
|
|
357
|
+
}
|
|
358
|
+
// COUNT is only a hint — chunk what actually came back.
|
|
359
|
+
for (let start = 0; start < ids.length; start += 500) {
|
|
360
|
+
yield* evalIndexMembers(prefix, kind, JSON.stringify(ids.slice(start, start + 500)))
|
|
361
|
+
}
|
|
362
|
+
} while (cursor !== "0")
|
|
363
|
+
} else {
|
|
364
|
+
const min = String(markerAt - 60_000)
|
|
365
|
+
let offset = 0
|
|
366
|
+
while (true) {
|
|
367
|
+
const scanned = Number(yield* evalIndexTailPage(prefix, kind, min, offset, 500))
|
|
368
|
+
if (scanned < 500) break
|
|
369
|
+
offset += scanned
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
yield* redis.send("SET", marker, String(bootAt))
|
|
373
|
+
}
|
|
374
|
+
}).pipe(Effect.orDie)
|
|
233
375
|
|
|
234
376
|
// Wake protocol: a queue-filtered waiter registry (same-process wake-ups
|
|
235
377
|
// never depend on the pub/sub round trip), with the channel carrying
|
|
@@ -756,24 +898,102 @@ export const make = (
|
|
|
756
898
|
list: (listOptions) =>
|
|
757
899
|
Effect.gen(function*() {
|
|
758
900
|
const limit = Math.max(1, listOptions.limit ?? 50)
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
901
|
+
const { metadata, name, queue, states } = listOptions
|
|
902
|
+
const orderBy = listOptions.orderBy ?? "enqueuedAt"
|
|
903
|
+
const order = listOptions.order ?? "desc"
|
|
904
|
+
// Routing: the narrowest structure whose zset score IS the
|
|
905
|
+
// requested order value; everything it does not pin stays a
|
|
906
|
+
// residual predicate applied in-script. Routing runs BEFORE the
|
|
907
|
+
// disabled-index check — a query another structure serves (e.g.
|
|
908
|
+
// name + terminal states ordered by finishedAt) must never die.
|
|
909
|
+
let sources: ReadonlyArray<string>
|
|
910
|
+
let residual: ResidualFilters
|
|
911
|
+
if (orderBy === "finishedAt") {
|
|
912
|
+
if (states === undefined || states.some((state) => !TERMINAL_STATES.has(state))) {
|
|
913
|
+
return yield* Effect.die(
|
|
914
|
+
new JobStore.ListOrderUnsupportedError({
|
|
915
|
+
orderBy,
|
|
916
|
+
message: "effect-mq: the Redis store serves orderBy \"finishedAt\" only with " +
|
|
917
|
+
"states ⊆ {completed, failed, cancelled} (the finished/terminal zsets carry that order)"
|
|
918
|
+
})
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
// ≤3 per-state sources, merged by (finishedAt, id) in-script.
|
|
922
|
+
const uniqueStates = [...new Set(states)]
|
|
923
|
+
sources = name === undefined
|
|
924
|
+
? uniqueStates.map((state) => `${prefix}:finished:${state}`)
|
|
925
|
+
: uniqueStates.map((state) => `${prefix}:terminal:${name}:${state}`)
|
|
926
|
+
residual = { queue, metadata }
|
|
927
|
+
} else if (orderBy === "runAt") {
|
|
928
|
+
const onlyDelayed = states !== undefined && states.length > 0 &&
|
|
929
|
+
states.every((state) => state === "delayed")
|
|
930
|
+
if (queue === undefined || !onlyDelayed) {
|
|
931
|
+
return yield* Effect.die(
|
|
932
|
+
new JobStore.ListOrderUnsupportedError({
|
|
933
|
+
orderBy,
|
|
934
|
+
message: "effect-mq: the Redis store serves orderBy \"runAt\" only with " +
|
|
935
|
+
"states: [\"delayed\"] and a queue (the delayed:<queue> zset carries that order)"
|
|
936
|
+
})
|
|
937
|
+
)
|
|
938
|
+
}
|
|
939
|
+
sources = [`${prefix}:delayed:${queue}`]
|
|
940
|
+
residual = { name, metadata }
|
|
941
|
+
} else if (name !== undefined) {
|
|
942
|
+
if (!indexes.name) {
|
|
943
|
+
return yield* Effect.die(
|
|
944
|
+
new ListIndexDisabledError({
|
|
945
|
+
index: "name",
|
|
946
|
+
message: "effect-mq: list({ name }) routes to the byname index, " +
|
|
947
|
+
"but RedisJobStoreOptions.indexes.name is disabled for this store"
|
|
948
|
+
})
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
sources = [`${prefix}:byname:${name}`]
|
|
952
|
+
residual = { queue, states, metadata }
|
|
953
|
+
} else if (queue !== undefined) {
|
|
954
|
+
if (!indexes.queue) {
|
|
955
|
+
return yield* Effect.die(
|
|
956
|
+
new ListIndexDisabledError({
|
|
957
|
+
index: "queue",
|
|
958
|
+
message: "effect-mq: list({ queue }) routes to the byqueue index, " +
|
|
959
|
+
"but RedisJobStoreOptions.indexes.queue is disabled for this store"
|
|
960
|
+
})
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
sources = [`${prefix}:byqueue:${queue}`]
|
|
964
|
+
residual = { states, metadata }
|
|
965
|
+
} else {
|
|
966
|
+
sources = [`${prefix}:all`]
|
|
967
|
+
residual = { states, metadata }
|
|
764
968
|
}
|
|
765
969
|
const reply: { items: ReadonlyArray<ReadonlyArray<string>> | Record<string, never>; more: boolean } = JSON
|
|
766
970
|
.parse(
|
|
767
|
-
yield* evalList(
|
|
971
|
+
yield* evalList(
|
|
972
|
+
prefix,
|
|
973
|
+
JSON.stringify(sources),
|
|
974
|
+
order,
|
|
975
|
+
JSON.stringify(residual),
|
|
976
|
+
listOptions.cursor ?? "",
|
|
977
|
+
limit
|
|
978
|
+
).pipe(Effect.mapError(storeError("list failed")))
|
|
768
979
|
)
|
|
769
980
|
const items = asArray(reply.items).map((flat) => toRecord(foldPairs(flat)))
|
|
770
981
|
const last = items[items.length - 1]
|
|
982
|
+
// The cursor value is the order field, which equals the routed
|
|
983
|
+
// zset's score for every row the script returned.
|
|
984
|
+
const orderValue = last === undefined
|
|
985
|
+
? 0
|
|
986
|
+
: orderBy === "enqueuedAt"
|
|
987
|
+
? last.enqueuedAt
|
|
988
|
+
: orderBy === "runAt"
|
|
989
|
+
? last.runAt
|
|
990
|
+
: last.finishedAt ?? 0
|
|
771
991
|
const result: JobStore.ListResult = {
|
|
772
992
|
items,
|
|
773
|
-
cursor: reply.more && last !== undefined ? `${
|
|
993
|
+
cursor: reply.more && last !== undefined ? `${orderValue}:${last.id}` : undefined
|
|
774
994
|
}
|
|
775
995
|
return result
|
|
776
|
-
})
|
|
996
|
+
}),
|
|
777
997
|
|
|
778
998
|
retry: (id) =>
|
|
779
999
|
Effect.gen(function*() {
|