ingenium-redis 0.0.1
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 +209 -0
- package/dist/index.cjs +233 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +176 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +228 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/client.ts +37 -0
- package/src/idempotency.ts +81 -0
- package/src/index.ts +53 -0
- package/src/queue.ts +240 -0
- package/src/rate-limit.ts +68 -0
- package/src/session.ts +52 -0
package/src/queue.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { QueueStore } from 'ingenium'
|
|
2
|
+
import type { RedisClientLike } from './client.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lua scripts backing {@link RedisQueueStore}. Every multi-step operation runs
|
|
6
|
+
* server-side in a single `EVAL` so the steps are atomic against concurrent
|
|
7
|
+
* workers on other replicas — Redis executes a script to completion before
|
|
8
|
+
* servicing any other command, so there is no window where (e.g.) two workers
|
|
9
|
+
* both ZREM the same id, or where a job is removed from `pending` but not yet
|
|
10
|
+
* recorded in-flight.
|
|
11
|
+
*
|
|
12
|
+
* The marker comment on line 1 of each script is load-bearing for the
|
|
13
|
+
* in-memory fake used by the test suite — see test/fake-client.ts, which
|
|
14
|
+
* dispatches on it. Real Redis ignores the comment.
|
|
15
|
+
*
|
|
16
|
+
* Key layout (KEYS, in the order every script receives them):
|
|
17
|
+
* 1. pending — ZSET, score = ready-time ms (notBefore), member = id.
|
|
18
|
+
* Picking the lowest score <= now gives FIFO with delay support.
|
|
19
|
+
* 2. jobs — HASH. Two fields per job: `<id>` holds the raw JSON payload,
|
|
20
|
+
* `<id>:a` holds the attempt count as a plain integer. Splitting
|
|
21
|
+
* the count into its own field lets `retry` use `HINCRBY` —
|
|
22
|
+
* no parsing of the (arbitrary, possibly `}`-containing) payload
|
|
23
|
+
* JSON inside Lua, which a single-regex envelope can't do safely.
|
|
24
|
+
* 3. inflight — SET of ids currently delivered but not yet acked/retried/failed.
|
|
25
|
+
* 4. failed — LIST (dead-letter); RPUSH on fail, LLEN for the count.
|
|
26
|
+
* 5. seq — STRING counter; INCR yields monotonic ids.
|
|
27
|
+
*
|
|
28
|
+
* The `:a` field suffix is an ARGV (not hard-coded in the script) only for the
|
|
29
|
+
* fake's benefit; in real Lua it's concatenated. Both store + fake build it the
|
|
30
|
+
* same way: `id .. ':a'`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Append a job to the tail. `now` (ARGV[1]) is the score so a freshly enqueued
|
|
35
|
+
* job is immediately ready and ordered after everything already pending —
|
|
36
|
+
* equal scores tie-break by member, and ids are monotonic via INCR, so equal
|
|
37
|
+
* scores still resolve to enqueue order. attempt starts at 1, mirroring
|
|
38
|
+
* MemoryQueueStore.
|
|
39
|
+
*/
|
|
40
|
+
const ENQUEUE_SCRIPT = `-- INGENIUM_QUEUE_ENQUEUE v1
|
|
41
|
+
local id = tostring(redis.call('INCR', KEYS[5]))
|
|
42
|
+
redis.call('HSET', KEYS[2], id, ARGV[2])
|
|
43
|
+
redis.call('HSET', KEYS[2], id .. ':a', '1')
|
|
44
|
+
redis.call('ZADD', KEYS[1], ARGV[1], id)
|
|
45
|
+
return id`
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Atomically pop the next ready job. ZRANGEBYSCORE with `-inf`..now and
|
|
49
|
+
* `LIMIT 0 1` yields the lowest-score member whose delay has elapsed; if none
|
|
50
|
+
* is ready (queue empty, or every pending job is still delayed) we return nil
|
|
51
|
+
* — matching MemoryQueueStore returning `null`. The chosen id is ZREM'd from
|
|
52
|
+
* pending and SADD'd to inflight in the same script so it can never be picked
|
|
53
|
+
* twice. Returns `{id, payloadJson, attempt}`.
|
|
54
|
+
*/
|
|
55
|
+
const NEXT_SCRIPT = `-- INGENIUM_QUEUE_NEXT v1
|
|
56
|
+
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
|
|
57
|
+
if not ids[1] then
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
local id = ids[1]
|
|
61
|
+
redis.call('ZREM', KEYS[1], id)
|
|
62
|
+
redis.call('SADD', KEYS[3], id)
|
|
63
|
+
local payload = redis.call('HGET', KEYS[2], id)
|
|
64
|
+
local attempt = redis.call('HGET', KEYS[2], id .. ':a')
|
|
65
|
+
return {id, payload, attempt}`
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove a completed in-flight job entirely. SREM + HDEL (both the payload and
|
|
69
|
+
* the attempt field) clears all trace; a no-op if the id is unknown (already
|
|
70
|
+
* acked), so ack is idempotent.
|
|
71
|
+
*/
|
|
72
|
+
const ACK_SCRIPT = `-- INGENIUM_QUEUE_ACK v1
|
|
73
|
+
redis.call('SREM', KEYS[3], ARGV[1])
|
|
74
|
+
redis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')
|
|
75
|
+
return 1`
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Re-enqueue an in-flight job after a delay, incrementing its attempt counter
|
|
79
|
+
* (the MUST-increment contract from QueueStore). `HINCRBY` on the `<id>:a`
|
|
80
|
+
* field is atomic and needs no payload parsing. ZADD back into pending at score
|
|
81
|
+
* `readyAt` (ARGV[2] = now + delayMs). If the id is not in-flight we do nothing
|
|
82
|
+
* — a job already acked/failed can't be retried.
|
|
83
|
+
*/
|
|
84
|
+
const RETRY_SCRIPT = `-- INGENIUM_QUEUE_RETRY v1
|
|
85
|
+
if redis.call('SREM', KEYS[3], ARGV[1]) == 0 then
|
|
86
|
+
return 0
|
|
87
|
+
end
|
|
88
|
+
if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 0 then
|
|
89
|
+
return 0
|
|
90
|
+
end
|
|
91
|
+
redis.call('HINCRBY', KEYS[2], ARGV[1] .. ':a', 1)
|
|
92
|
+
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
|
|
93
|
+
return 1`
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Move an in-flight job to the dead-letter list. We RPUSH a self-contained
|
|
97
|
+
* envelope `{"d":<payload>,"a":<attempt>}` onto `failed` (built by concatenating
|
|
98
|
+
* the raw payload JSON and the integer attempt — no re-encode of the payload),
|
|
99
|
+
* then drop the in-flight marker and both hash fields. No-op if the id is not
|
|
100
|
+
* in-flight, so fail is idempotent.
|
|
101
|
+
*/
|
|
102
|
+
const FAIL_SCRIPT = `-- INGENIUM_QUEUE_FAIL v1
|
|
103
|
+
if redis.call('SREM', KEYS[3], ARGV[1]) == 0 then
|
|
104
|
+
return 0
|
|
105
|
+
end
|
|
106
|
+
local payload = redis.call('HGET', KEYS[2], ARGV[1])
|
|
107
|
+
if payload then
|
|
108
|
+
local attempt = redis.call('HGET', KEYS[2], ARGV[1] .. ':a') or '1'
|
|
109
|
+
local envelope = '{"id":"' .. ARGV[1] .. '","d":' .. payload .. ',"a":' .. attempt .. '}'
|
|
110
|
+
redis.call('RPUSH', KEYS[4], envelope)
|
|
111
|
+
redis.call('HDEL', KEYS[2], ARGV[1], ARGV[1] .. ':a')
|
|
112
|
+
end
|
|
113
|
+
return 1`
|
|
114
|
+
|
|
115
|
+
/** `size()` = ZCARD pending (includes delayed jobs). */
|
|
116
|
+
const SIZE_SCRIPT = `-- INGENIUM_QUEUE_SIZE v1
|
|
117
|
+
return redis.call('ZCARD', KEYS[1])`
|
|
118
|
+
|
|
119
|
+
/** `failedCount()` = LLEN of the dead-letter list. */
|
|
120
|
+
const FAILED_COUNT_SCRIPT = `-- INGENIUM_QUEUE_FAILED_COUNT v1
|
|
121
|
+
return redis.call('LLEN', KEYS[4])`
|
|
122
|
+
|
|
123
|
+
export interface RedisQueueStoreOptions {
|
|
124
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
125
|
+
client: RedisClientLike
|
|
126
|
+
/**
|
|
127
|
+
* Key prefix for every structure owned by this queue instance. Default
|
|
128
|
+
* `'ingenium:queue:'`. Two queues that must not share work need distinct
|
|
129
|
+
* prefixes (e.g. `'ingenium:queue:emails:'`).
|
|
130
|
+
*/
|
|
131
|
+
prefix?: string
|
|
132
|
+
/**
|
|
133
|
+
* Clock used to stamp ready-times (ZSET scores). Defaults to `Date.now`.
|
|
134
|
+
* Overridable so tests can drive virtual time; production code never sets it.
|
|
135
|
+
*/
|
|
136
|
+
now?: () => number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Redis-backed {@link QueueStore}. A FIFO job queue with delayed retries and a
|
|
141
|
+
* dead-letter list, sharing state across replicas so any worker on any pod can
|
|
142
|
+
* pick up any job.
|
|
143
|
+
*
|
|
144
|
+
* Atomicity: every operation that touches more than one key (`next`, `retry`,
|
|
145
|
+
* `fail`, and—for uniformity and to keep {@link RedisClientLike} tiny—`enqueue`,
|
|
146
|
+
* `ack`, `size`, `failedCount`) runs as a single Lua `EVAL`. This is what keeps
|
|
147
|
+
* the client surface unchanged: we never need MULTI/WATCH or any command beyond
|
|
148
|
+
* `eval`. Redis runs each script to completion atomically, so concurrent workers
|
|
149
|
+
* can't double-deliver a job or lose a payload between the ZREM and the
|
|
150
|
+
* in-flight SADD.
|
|
151
|
+
*
|
|
152
|
+
* Delivery guarantee: at-least-once. A job that is `next()`-ed but whose worker
|
|
153
|
+
* crashes before `ack`/`retry`/`fail` stays in the `inflight` set and in the
|
|
154
|
+
* `jobs` hash, but NOT in `pending` — it will not be re-delivered automatically
|
|
155
|
+
* by this store (there is no visibility-timeout sweeper). That matches
|
|
156
|
+
* {@link MemoryQueueStore}, which also leaves crashed jobs stuck in its
|
|
157
|
+
* in-flight map. Add a reaper that re-enqueues stale inflight ids if you need
|
|
158
|
+
* crash recovery; the data model (inflight SET + jobs HASH) supports it.
|
|
159
|
+
*
|
|
160
|
+
* `size()` returns the pending count INCLUDING delayed (not-yet-ready) jobs,
|
|
161
|
+
* mirroring `MemoryQueueStore.size()` which returns `pending.length`. Delayed
|
|
162
|
+
* jobs live in the same ZSET with a future score, so `ZCARD` counts them.
|
|
163
|
+
*/
|
|
164
|
+
export class RedisQueueStore<TData> implements QueueStore<TData> {
|
|
165
|
+
private readonly client: RedisClientLike
|
|
166
|
+
private readonly now: () => number
|
|
167
|
+
/** KEYS passed to every script, in fixed order: pending, jobs, inflight, failed, seq. */
|
|
168
|
+
private readonly keys: readonly [string, string, string, string, string]
|
|
169
|
+
|
|
170
|
+
constructor(opts: RedisQueueStoreOptions) {
|
|
171
|
+
this.client = opts.client
|
|
172
|
+
this.now = opts.now ?? Date.now
|
|
173
|
+
const prefix = opts.prefix ?? 'ingenium:queue:'
|
|
174
|
+
this.keys = [
|
|
175
|
+
prefix + 'pending',
|
|
176
|
+
prefix + 'jobs',
|
|
177
|
+
prefix + 'inflight',
|
|
178
|
+
prefix + 'failed',
|
|
179
|
+
prefix + 'seq',
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async enqueue(data: TData): Promise<{ id: string }> {
|
|
184
|
+
const id = (await this.client.eval(ENQUEUE_SCRIPT, {
|
|
185
|
+
keys: this.keys,
|
|
186
|
+
arguments: [String(this.now()), JSON.stringify(data)],
|
|
187
|
+
})) as string
|
|
188
|
+
return { id: String(id) }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async next(): Promise<{ id: string; data: TData; attempt: number } | null> {
|
|
192
|
+
const result = (await this.client.eval(NEXT_SCRIPT, {
|
|
193
|
+
keys: this.keys,
|
|
194
|
+
arguments: [String(this.now())],
|
|
195
|
+
})) as [string, string | null, string | null] | null
|
|
196
|
+
|
|
197
|
+
if (result == null || !Array.isArray(result)) return null
|
|
198
|
+
const [id, payload, attempt] = result
|
|
199
|
+
if (id == null || payload == null) return null
|
|
200
|
+
let data: TData
|
|
201
|
+
try {
|
|
202
|
+
data = JSON.parse(payload) as TData
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
return { id: String(id), data, attempt: Number(attempt) || 1 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async ack(id: string): Promise<void> {
|
|
210
|
+
await this.client.eval(ACK_SCRIPT, { keys: this.keys, arguments: [id] })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async retry(id: string, delayMs: number): Promise<void> {
|
|
214
|
+
const readyAt = this.now() + Math.max(0, delayMs)
|
|
215
|
+
await this.client.eval(RETRY_SCRIPT, {
|
|
216
|
+
keys: this.keys,
|
|
217
|
+
arguments: [id, String(readyAt)],
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async fail(id: string): Promise<void> {
|
|
222
|
+
await this.client.eval(FAIL_SCRIPT, { keys: this.keys, arguments: [id] })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async size(): Promise<number> {
|
|
226
|
+
const n = (await this.client.eval(SIZE_SCRIPT, {
|
|
227
|
+
keys: this.keys,
|
|
228
|
+
arguments: [],
|
|
229
|
+
})) as number
|
|
230
|
+
return Number(n) || 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async failedCount(): Promise<number> {
|
|
234
|
+
const n = (await this.client.eval(FAILED_COUNT_SCRIPT, {
|
|
235
|
+
keys: this.keys,
|
|
236
|
+
arguments: [],
|
|
237
|
+
})) as number
|
|
238
|
+
return Number(n) || 0
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RateLimitStore } from 'ingenium'
|
|
2
|
+
import type { RedisClientLike } from './client.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Atomic fixed-window counter. INCR is atomic on its own; the `PEXPIRE`
|
|
6
|
+
* piggybacks on the first hit so the window starts the moment the counter is
|
|
7
|
+
* created. The trailing `PTTL` gives us the precise reset time without a
|
|
8
|
+
* second round-trip and without trusting the caller's clock.
|
|
9
|
+
*
|
|
10
|
+
* The comment marker on line 1 is load-bearing for the in-memory fake used
|
|
11
|
+
* by the test suite — see test/fake-client.ts. Real Redis ignores it.
|
|
12
|
+
*/
|
|
13
|
+
const HIT_SCRIPT = `-- INGENIUM_RATELIMIT_HIT v1
|
|
14
|
+
local current = redis.call('INCR', KEYS[1])
|
|
15
|
+
if current == 1 then
|
|
16
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
17
|
+
end
|
|
18
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
19
|
+
return {current, ttl}`
|
|
20
|
+
|
|
21
|
+
export interface RedisRateLimitStoreOptions {
|
|
22
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
23
|
+
client: RedisClientLike
|
|
24
|
+
/** Key prefix for every entry. Default `'ingenium:rl:'`. */
|
|
25
|
+
prefix?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Redis-backed {@link RateLimitStore}. Uses a single Lua call per hit so the
|
|
30
|
+
* INCR + PEXPIRE + PTTL trio is atomic — no race where two replicas both see
|
|
31
|
+
* `count == 1` and each set their own expiry, and no race where the counter
|
|
32
|
+
* exists without a TTL because the expire happened in a separate round-trip
|
|
33
|
+
* that lost.
|
|
34
|
+
*
|
|
35
|
+
* `resetAt` is computed from `PTTL` on the server side, so the value is
|
|
36
|
+
* consistent across replicas even if their clocks drift. We add `Date.now()`
|
|
37
|
+
* locally only to produce the absolute timestamp the rest of Ingenium
|
|
38
|
+
* works with; a small clock skew there affects header reporting only, not
|
|
39
|
+
* the actual rate-limit decision (which Redis owns).
|
|
40
|
+
*/
|
|
41
|
+
export class RedisRateLimitStore implements RateLimitStore {
|
|
42
|
+
private readonly client: RedisClientLike
|
|
43
|
+
private readonly prefix: string
|
|
44
|
+
|
|
45
|
+
constructor(opts: RedisRateLimitStoreOptions) {
|
|
46
|
+
this.client = opts.client
|
|
47
|
+
this.prefix = opts.prefix ?? 'ingenium:rl:'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async hit(key: string, windowMs: number): Promise<{ count: number; resetAt: number }> {
|
|
51
|
+
const result = (await this.client.eval(HIT_SCRIPT, {
|
|
52
|
+
keys: [this.prefix + key],
|
|
53
|
+
arguments: [String(windowMs)],
|
|
54
|
+
})) as [number, number]
|
|
55
|
+
|
|
56
|
+
if (!Array.isArray(result) || result.length !== 2) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`RedisRateLimitStore: unexpected EVAL result shape: ${JSON.stringify(result)}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
const [count, ttlMs] = result
|
|
62
|
+
return { count, resetAt: Date.now() + Math.max(0, ttlMs) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async reset(key: string): Promise<void> {
|
|
66
|
+
await this.client.del(this.prefix + key)
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SessionStore } from 'ingenium'
|
|
2
|
+
import type { RedisClientLike } from './client.ts'
|
|
3
|
+
|
|
4
|
+
export interface RedisSessionStoreOptions {
|
|
5
|
+
/** Connected Redis client. Caller owns lifecycle. */
|
|
6
|
+
client: RedisClientLike
|
|
7
|
+
/** Key prefix for every entry. Default `'ingenium:sess:'`. */
|
|
8
|
+
prefix?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Redis-backed {@link SessionStore}. JSON-encodes session data and uses
|
|
13
|
+
* `SET ... EX` so Redis owns TTL expiry — no sweeper, no clock drift between
|
|
14
|
+
* the cookie expiry and the stored value.
|
|
15
|
+
*
|
|
16
|
+
* The store does NOT manage the client connection. Create + `.connect()` your
|
|
17
|
+
* `createClient(...)` before constructing the store; close it during your
|
|
18
|
+
* graceful shutdown hook.
|
|
19
|
+
*/
|
|
20
|
+
export class RedisSessionStore implements SessionStore {
|
|
21
|
+
private readonly client: RedisClientLike
|
|
22
|
+
private readonly prefix: string
|
|
23
|
+
|
|
24
|
+
constructor(opts: RedisSessionStoreOptions) {
|
|
25
|
+
this.client = opts.client
|
|
26
|
+
this.prefix = opts.prefix ?? 'ingenium:sess:'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(id: string): Promise<Record<string, unknown> | null> {
|
|
30
|
+
const raw = await this.client.get(this.prefix + id)
|
|
31
|
+
if (raw === null) return null
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw)
|
|
34
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
35
|
+
return parsed as Record<string, unknown>
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async set(id: string, data: Record<string, unknown>, ttlSeconds: number): Promise<void> {
|
|
42
|
+
await this.client.set(this.prefix + id, JSON.stringify(data), { EX: ttlSeconds })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async destroy(id: string): Promise<void> {
|
|
46
|
+
await this.client.del(this.prefix + id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async touch(id: string, ttlSeconds: number): Promise<void> {
|
|
50
|
+
await this.client.expire(this.prefix + id, ttlSeconds)
|
|
51
|
+
}
|
|
52
|
+
}
|