ingenium 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/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { MemoryQueueStore } from './store-memory.ts'
|
|
2
|
+
import type {
|
|
3
|
+
FailedJob,
|
|
4
|
+
QueueOptions,
|
|
5
|
+
QueueStore,
|
|
6
|
+
QueueWorker,
|
|
7
|
+
RetryPolicy,
|
|
8
|
+
} from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** Default exponential backoff: 100ms, 400ms, 1.6s, 6.4s, ... (4^(n-1) * 100). */
|
|
11
|
+
const DEFAULT_RETRIES: RetryPolicy = {
|
|
12
|
+
attempts: 3,
|
|
13
|
+
backoffMs: (attempt: number) => 100 * Math.pow(4, attempt - 1),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeRetries(retries: number | RetryPolicy | undefined): RetryPolicy {
|
|
17
|
+
if (retries === undefined) return DEFAULT_RETRIES
|
|
18
|
+
if (typeof retries === 'number') {
|
|
19
|
+
return { attempts: Math.max(1, retries), backoffMs: DEFAULT_RETRIES.backoffMs }
|
|
20
|
+
}
|
|
21
|
+
return retries
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A single named background queue. Wraps a {@link QueueStore} with a worker
|
|
26
|
+
* pool, retry/backoff logic, pause/resume controls, and a `drain()` for
|
|
27
|
+
* graceful shutdown.
|
|
28
|
+
*
|
|
29
|
+
* The pool is event-driven: when a slot frees up, the queue immediately
|
|
30
|
+
* tries to pull another job; if none is ready (empty or all delayed), the
|
|
31
|
+
* pool sleeps until either:
|
|
32
|
+
* - a new `add()` call wakes it, or
|
|
33
|
+
* - the earliest delayed job's `notBefore` elapses (timer-based wake).
|
|
34
|
+
*
|
|
35
|
+
* All timers are `unref()`'d so the queue alone never keeps the event loop alive.
|
|
36
|
+
*/
|
|
37
|
+
export class IngeniumQueue<TData = unknown> {
|
|
38
|
+
readonly name: string
|
|
39
|
+
private readonly store: QueueStore<TData>
|
|
40
|
+
private readonly worker: QueueWorker<TData>
|
|
41
|
+
private readonly retries: RetryPolicy
|
|
42
|
+
private readonly concurrency: number
|
|
43
|
+
private readonly onFailed: ((job: FailedJob<TData>) => void | Promise<void>) | undefined
|
|
44
|
+
|
|
45
|
+
/** Active worker slot count. When `< concurrency`, pull more work. */
|
|
46
|
+
private active = 0
|
|
47
|
+
/** Whether `pause()` has been called and `resume()` not yet. */
|
|
48
|
+
private paused = false
|
|
49
|
+
/** Whether `drain()`/close has been called. No new jobs accepted. */
|
|
50
|
+
private closed = false
|
|
51
|
+
/** Timer for waking the pool when a delayed retry becomes due. */
|
|
52
|
+
private wakeTimer: NodeJS.Timeout | null = null
|
|
53
|
+
/** Resolvers waiting for `active` to hit 0 (used by `drain()`). */
|
|
54
|
+
private idleWaiters: (() => void)[] = []
|
|
55
|
+
/** Set when the pool is started — protects against double-start. */
|
|
56
|
+
private started = false
|
|
57
|
+
|
|
58
|
+
constructor(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>) {
|
|
59
|
+
this.name = name
|
|
60
|
+
this.store = opts.store ?? new MemoryQueueStore<TData>()
|
|
61
|
+
this.worker = worker
|
|
62
|
+
this.retries = normalizeRetries(opts.retries)
|
|
63
|
+
this.concurrency = Math.max(1, opts.concurrency ?? 1)
|
|
64
|
+
this.onFailed = opts.onFailed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start the worker pool. Idempotent — safe to call multiple times. The
|
|
69
|
+
* pool is also implicitly started by the first `add()` call so direct
|
|
70
|
+
* invocation is optional.
|
|
71
|
+
*/
|
|
72
|
+
start(): void {
|
|
73
|
+
if (this.started) return
|
|
74
|
+
this.started = true
|
|
75
|
+
this.pump()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Enqueue a job. Returns the assigned id. */
|
|
79
|
+
async add(data: TData): Promise<{ id: string }> {
|
|
80
|
+
if (this.closed) {
|
|
81
|
+
throw new Error(`ingenium: queue "${this.name}" is closed (no new jobs accepted)`)
|
|
82
|
+
}
|
|
83
|
+
const handle = await this.store.enqueue(data)
|
|
84
|
+
// Lazy-start: first add() triggers worker pool boot if `start()` wasn't called.
|
|
85
|
+
if (!this.started) this.start()
|
|
86
|
+
else this.pump()
|
|
87
|
+
return handle
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Approximate number of pending jobs. */
|
|
91
|
+
size(): Promise<number> {
|
|
92
|
+
return this.store.size()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Number of jobs in the dead-letter list. */
|
|
96
|
+
failedCount(): Promise<number> {
|
|
97
|
+
return this.store.failedCount()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Empty the dead-letter list. Only effective when the underlying store
|
|
102
|
+
* is the default {@link MemoryQueueStore}; custom stores should provide
|
|
103
|
+
* their own clearing surface.
|
|
104
|
+
*/
|
|
105
|
+
clearFailed(): void {
|
|
106
|
+
if (this.store instanceof MemoryQueueStore) this.store.clearFailed()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stop pulling new jobs from the store. In-flight jobs continue to run
|
|
111
|
+
* until they complete. Idempotent.
|
|
112
|
+
*/
|
|
113
|
+
pause(): void {
|
|
114
|
+
this.paused = true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Resume pulling jobs. Wakes the pool. */
|
|
118
|
+
resume(): void {
|
|
119
|
+
if (!this.paused) return
|
|
120
|
+
this.paused = false
|
|
121
|
+
this.pump()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wait for all in-flight jobs to complete, then stop accepting new ones.
|
|
126
|
+
* If `timeoutMs` elapses first, resolve anyway — the orphaned jobs keep
|
|
127
|
+
* running until they naturally finish (JS can't cancel a Promise), but
|
|
128
|
+
* the framework stops waiting.
|
|
129
|
+
*
|
|
130
|
+
* Returns `true` if the queue drained cleanly; `false` on timeout.
|
|
131
|
+
*/
|
|
132
|
+
async drain(timeoutMs?: number): Promise<boolean> {
|
|
133
|
+
this.closed = true
|
|
134
|
+
this.paused = true // stop pulling new work even if currently active
|
|
135
|
+
if (this.active === 0) {
|
|
136
|
+
this.clearWakeTimer()
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
return new Promise<boolean>((resolve) => {
|
|
140
|
+
let settled = false
|
|
141
|
+
const onIdle = (): void => {
|
|
142
|
+
if (settled) return
|
|
143
|
+
settled = true
|
|
144
|
+
if (timer) clearTimeout(timer)
|
|
145
|
+
this.clearWakeTimer()
|
|
146
|
+
resolve(true)
|
|
147
|
+
}
|
|
148
|
+
this.idleWaiters.push(onIdle)
|
|
149
|
+
const timer = timeoutMs !== undefined
|
|
150
|
+
? setTimeout(() => {
|
|
151
|
+
if (settled) return
|
|
152
|
+
settled = true
|
|
153
|
+
// Drop our waiter so the pool doesn't re-resolve us.
|
|
154
|
+
const idx = this.idleWaiters.indexOf(onIdle)
|
|
155
|
+
if (idx >= 0) this.idleWaiters.splice(idx, 1)
|
|
156
|
+
this.clearWakeTimer()
|
|
157
|
+
resolve(false)
|
|
158
|
+
}, timeoutMs)
|
|
159
|
+
: null
|
|
160
|
+
timer?.unref?.()
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pump the pool: while we have free slots and we're not paused, pull jobs
|
|
166
|
+
* and dispatch them. No-op when paused / saturated. Re-entrant: every
|
|
167
|
+
* job completion calls `pump()` again to fill the slot.
|
|
168
|
+
*/
|
|
169
|
+
private pump(): void {
|
|
170
|
+
if (this.paused) {
|
|
171
|
+
this.checkIdle()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
while (this.active < this.concurrency) {
|
|
175
|
+
// Synchronously check store state via a microtask. The store API is
|
|
176
|
+
// async (so a Redis adapter works), so each pull is a Promise we don't
|
|
177
|
+
// await inline — we just kick it off and let `runOne` increment/decrement
|
|
178
|
+
// `active` around the actual worker invocation.
|
|
179
|
+
const slotOpen = this.tryFillSlot()
|
|
180
|
+
if (!slotOpen) break
|
|
181
|
+
}
|
|
182
|
+
this.checkIdle()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Attempts to take one job from the store and run it. Returns `false` if
|
|
187
|
+
* the store is empty (or all-delayed) so the caller stops looping.
|
|
188
|
+
*
|
|
189
|
+
* NOTE: we increment `active` BEFORE the async `next()` resolves so a
|
|
190
|
+
* burst of synchronous `pump()` calls doesn't over-subscribe the pool.
|
|
191
|
+
* We decrement on the `null` path.
|
|
192
|
+
*/
|
|
193
|
+
private tryFillSlot(): boolean {
|
|
194
|
+
this.active++
|
|
195
|
+
void this.runOne().catch(() => {
|
|
196
|
+
// runOne handles all errors internally; this catch is a defensive
|
|
197
|
+
// backstop so an unexpected bug doesn't unhandle a rejection.
|
|
198
|
+
})
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async runOne(): Promise<void> {
|
|
203
|
+
let job: { id: string; data: TData; attempt: number } | null = null
|
|
204
|
+
try {
|
|
205
|
+
job = await this.store.next()
|
|
206
|
+
} catch {
|
|
207
|
+
// Store failure pulling the next job — release slot and back off.
|
|
208
|
+
this.active--
|
|
209
|
+
this.checkIdle()
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (job === null) {
|
|
213
|
+
// Empty (or all delayed). Release the speculative slot, schedule a
|
|
214
|
+
// wake for the earliest delayed job (if any), and stop pulling.
|
|
215
|
+
this.active--
|
|
216
|
+
this.scheduleDelayedWake()
|
|
217
|
+
this.checkIdle()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let lastError: unknown = undefined
|
|
222
|
+
try {
|
|
223
|
+
await this.worker({ id: job.id, data: job.data, attempt: job.attempt })
|
|
224
|
+
await this.store.ack(job.id)
|
|
225
|
+
} catch (err) {
|
|
226
|
+
lastError = err
|
|
227
|
+
const attempt = job.attempt
|
|
228
|
+
if (attempt < this.retries.attempts) {
|
|
229
|
+
const delay = Math.max(0, this.retries.backoffMs(attempt))
|
|
230
|
+
try {
|
|
231
|
+
await this.store.retry(job.id, delay)
|
|
232
|
+
} catch {
|
|
233
|
+
// If retry bookkeeping fails, fall back to fail() so the job
|
|
234
|
+
// doesn't get stuck in-flight forever.
|
|
235
|
+
await this.safeFail(job, lastError)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
await this.safeFail(job, lastError)
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
this.active--
|
|
242
|
+
// Refill the freed slot on the next macrotask, not synchronously. A
|
|
243
|
+
// synchronous refill would chain pickup of the *next* job into the same
|
|
244
|
+
// microtask run as this job's completion, so the pool would never be
|
|
245
|
+
// observably idle between jobs (active would dip and immediately rise
|
|
246
|
+
// within one turn). Deferring to setImmediate yields a macrotask
|
|
247
|
+
// boundary where `active` reflects only genuinely-running jobs.
|
|
248
|
+
const t = setImmediate(() => this.pump())
|
|
249
|
+
t.unref?.()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async safeFail(
|
|
254
|
+
job: { id: string; data: TData; attempt: number },
|
|
255
|
+
lastError: unknown,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
await this.store.fail(job.id)
|
|
259
|
+
} catch {
|
|
260
|
+
// If even fail() throws, we've done what we can — the job stays
|
|
261
|
+
// in-flight in the store, which is at-least-once-correct.
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (this.onFailed) {
|
|
265
|
+
try {
|
|
266
|
+
await this.onFailed({ id: job.id, data: job.data, attempt: job.attempt, lastError })
|
|
267
|
+
} catch {
|
|
268
|
+
// onFailed errors are observation-only; swallow.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* If the store has only delayed entries (e.g. just-retried jobs whose
|
|
275
|
+
* backoff hasn't elapsed), schedule a one-shot wake when the earliest
|
|
276
|
+
* delay fires so we don't spin or sleep forever.
|
|
277
|
+
*
|
|
278
|
+
* Only the default in-memory store exposes `earliestPendingAt`; for
|
|
279
|
+
* custom stores we don't poll — we rely on the next `add()` to wake us.
|
|
280
|
+
*/
|
|
281
|
+
private scheduleDelayedWake(): void {
|
|
282
|
+
if (!(this.store instanceof MemoryQueueStore)) return
|
|
283
|
+
const earliest = this.store.earliestPendingAt()
|
|
284
|
+
if (earliest === null) return
|
|
285
|
+
const delay = Math.max(1, earliest - Date.now())
|
|
286
|
+
this.clearWakeTimer()
|
|
287
|
+
this.wakeTimer = setTimeout(() => {
|
|
288
|
+
this.wakeTimer = null
|
|
289
|
+
this.pump()
|
|
290
|
+
}, delay)
|
|
291
|
+
this.wakeTimer.unref?.()
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private clearWakeTimer(): void {
|
|
295
|
+
if (this.wakeTimer) {
|
|
296
|
+
clearTimeout(this.wakeTimer)
|
|
297
|
+
this.wakeTimer = null
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private checkIdle(): void {
|
|
302
|
+
if (this.active !== 0 || this.idleWaiters.length === 0) return
|
|
303
|
+
const waiters = this.idleWaiters.splice(0, this.idleWaiters.length)
|
|
304
|
+
for (const w of waiters) w()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { IngeniumQueue } from './queue.ts'
|
|
2
|
+
import type { QueueOptions, QueueWorker } from './types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps queue names → {@link IngeniumQueue} instances. Held by `IngeniumApp`,
|
|
6
|
+
* mirroring the shape of {@link CronRegistry}.
|
|
7
|
+
*
|
|
8
|
+
* Lookup is O(1). Names must be unique within an app — re-registering the
|
|
9
|
+
* same name throws (mirrors how `app.get('/users')` would conflict if you
|
|
10
|
+
* registered the same path twice with the same method).
|
|
11
|
+
*/
|
|
12
|
+
export class QueueRegistry {
|
|
13
|
+
private readonly queues: Map<string, IngeniumQueue<unknown>> = new Map()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register a new queue. Returns the created instance. Throws if a queue
|
|
17
|
+
* with `name` is already registered.
|
|
18
|
+
*/
|
|
19
|
+
register<TData>(
|
|
20
|
+
name: string,
|
|
21
|
+
opts: QueueOptions<TData>,
|
|
22
|
+
worker: QueueWorker<TData>,
|
|
23
|
+
): IngeniumQueue<TData> {
|
|
24
|
+
if (this.queues.has(name)) {
|
|
25
|
+
throw new Error(`ingenium: queue "${name}" is already registered`)
|
|
26
|
+
}
|
|
27
|
+
const queue = new IngeniumQueue<TData>(name, opts, worker)
|
|
28
|
+
this.queues.set(name, queue as unknown as IngeniumQueue<unknown>)
|
|
29
|
+
return queue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Look up a queue. Throws if not registered (typos surface immediately). */
|
|
33
|
+
get<TData = unknown>(name: string): IngeniumQueue<TData> {
|
|
34
|
+
const queue = this.queues.get(name)
|
|
35
|
+
if (!queue) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`ingenium: queue "${name}" is not registered (call app.queue("${name}", ...) first)`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
return queue as unknown as IngeniumQueue<TData>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Has a queue with this name been registered? */
|
|
44
|
+
has(name: string): boolean {
|
|
45
|
+
return this.queues.has(name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Number of registered queues. */
|
|
49
|
+
count(): number {
|
|
50
|
+
return this.queues.size
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** All registered queue names (insertion order). */
|
|
54
|
+
names(): string[] {
|
|
55
|
+
return [...this.queues.keys()]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Start the worker pool of every registered queue. Called by the app's
|
|
60
|
+
* composition step so workers don't process jobs before the app is ready.
|
|
61
|
+
*/
|
|
62
|
+
startAll(): void {
|
|
63
|
+
for (const q of this.queues.values()) q.start()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drain every queue concurrently. Resolves when all queues either finish
|
|
68
|
+
* their in-flight work or hit `timeoutMs`. The returned object reports
|
|
69
|
+
* which queues drained cleanly vs timed out — useful for shutdown logs.
|
|
70
|
+
*/
|
|
71
|
+
async drainAll(timeoutMs?: number): Promise<{ clean: string[]; timedOut: string[] }> {
|
|
72
|
+
const entries = [...this.queues.entries()]
|
|
73
|
+
const results = await Promise.all(entries.map(([, q]) => q.drain(timeoutMs)))
|
|
74
|
+
const clean: string[] = []
|
|
75
|
+
const timedOut: string[] = []
|
|
76
|
+
entries.forEach(([name], i) => {
|
|
77
|
+
if (results[i]) clean.push(name)
|
|
78
|
+
else timedOut.push(name)
|
|
79
|
+
})
|
|
80
|
+
return { clean, timedOut }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { QueueStore } from './types.ts'
|
|
2
|
+
|
|
3
|
+
interface PendingEntry<TData> {
|
|
4
|
+
id: string
|
|
5
|
+
data: TData
|
|
6
|
+
attempt: number
|
|
7
|
+
/** When `> Date.now()`, the job is delayed (used by retries). */
|
|
8
|
+
notBefore: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface InFlightEntry<TData> {
|
|
12
|
+
id: string
|
|
13
|
+
data: TData
|
|
14
|
+
attempt: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-process FIFO queue store. Backs {@link IngeniumQueue} when no custom
|
|
19
|
+
* store is supplied. Suitable for single-instance deployments and tests.
|
|
20
|
+
*
|
|
21
|
+
* Layout:
|
|
22
|
+
* - `pending`: ordered list of jobs ready to be picked up. Delayed jobs
|
|
23
|
+
* (post-retry backoff) sit here too — `next()` skips entries whose
|
|
24
|
+
* `notBefore` hasn't elapsed yet, so callers should poll on a timer.
|
|
25
|
+
* - `inFlight`: jobs that have been `next()`-ed but not yet `ack`/`retry`/`fail`-ed.
|
|
26
|
+
* - `failed`: dead-letter list. Persists until `clearFailed()` is called.
|
|
27
|
+
*
|
|
28
|
+
* No background timers — purely event-driven via the queue worker pool.
|
|
29
|
+
*/
|
|
30
|
+
export class MemoryQueueStore<TData> implements QueueStore<TData> {
|
|
31
|
+
private readonly pending: PendingEntry<TData>[] = []
|
|
32
|
+
private readonly inFlight: Map<string, InFlightEntry<TData>> = new Map()
|
|
33
|
+
private readonly failed: { id: string; data: TData; attempt: number }[] = []
|
|
34
|
+
private nextId = 1
|
|
35
|
+
|
|
36
|
+
enqueue(data: TData): Promise<{ id: string }> {
|
|
37
|
+
const id = String(this.nextId++)
|
|
38
|
+
this.pending.push({ id, data, attempt: 1, notBefore: 0 })
|
|
39
|
+
return Promise.resolve({ id })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
next(): Promise<{ id: string; data: TData; attempt: number } | null> {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
// Find the first pending entry whose delay has elapsed.
|
|
45
|
+
for (let i = 0; i < this.pending.length; i++) {
|
|
46
|
+
const entry = this.pending[i]!
|
|
47
|
+
if (entry.notBefore <= now) {
|
|
48
|
+
this.pending.splice(i, 1)
|
|
49
|
+
const inflight: InFlightEntry<TData> = {
|
|
50
|
+
id: entry.id,
|
|
51
|
+
data: entry.data,
|
|
52
|
+
attempt: entry.attempt,
|
|
53
|
+
}
|
|
54
|
+
this.inFlight.set(entry.id, inflight)
|
|
55
|
+
return Promise.resolve({ id: entry.id, data: entry.data, attempt: entry.attempt })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return Promise.resolve(null)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ack(id: string): Promise<void> {
|
|
62
|
+
this.inFlight.delete(id)
|
|
63
|
+
return Promise.resolve()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
retry(id: string, delayMs: number): Promise<void> {
|
|
67
|
+
const entry = this.inFlight.get(id)
|
|
68
|
+
if (!entry) return Promise.resolve()
|
|
69
|
+
this.inFlight.delete(id)
|
|
70
|
+
this.pending.push({
|
|
71
|
+
id: entry.id,
|
|
72
|
+
data: entry.data,
|
|
73
|
+
attempt: entry.attempt + 1,
|
|
74
|
+
notBefore: Date.now() + Math.max(0, delayMs),
|
|
75
|
+
})
|
|
76
|
+
return Promise.resolve()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fail(id: string): Promise<void> {
|
|
80
|
+
const entry = this.inFlight.get(id)
|
|
81
|
+
if (!entry) return Promise.resolve()
|
|
82
|
+
this.inFlight.delete(id)
|
|
83
|
+
this.failed.push({ id: entry.id, data: entry.data, attempt: entry.attempt })
|
|
84
|
+
return Promise.resolve()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
size(): Promise<number> {
|
|
88
|
+
return Promise.resolve(this.pending.length)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
failedCount(): Promise<number> {
|
|
92
|
+
return Promise.resolve(this.failed.length)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @internal Used by `IngeniumQueue.clearFailed()`. */
|
|
96
|
+
clearFailed(): void {
|
|
97
|
+
this.failed.length = 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @internal Used by `IngeniumQueue.drain()` to know if work is outstanding. */
|
|
101
|
+
inFlightCount(): number {
|
|
102
|
+
return this.inFlight.size
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @internal Earliest `notBefore` of any pending entry, or `null` if none. */
|
|
106
|
+
earliestPendingAt(): number | null {
|
|
107
|
+
let min: number | null = null
|
|
108
|
+
for (const e of this.pending) {
|
|
109
|
+
if (min === null || e.notBefore < min) min = e.notBefore
|
|
110
|
+
}
|
|
111
|
+
return min
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-job type surface for Ingenium.
|
|
3
|
+
*
|
|
4
|
+
* The core abstraction is a {@link QueueStore} — a pluggable persistence layer
|
|
5
|
+
* for FIFO jobs with retries and a dead-letter list. The default implementation
|
|
6
|
+
* ({@link MemoryQueueStore}) keeps everything in process; a Redis adapter can
|
|
7
|
+
* land later by simply implementing this interface.
|
|
8
|
+
*
|
|
9
|
+
* A {@link IngeniumQueue} wraps a store with a worker pool, retry policy, and
|
|
10
|
+
* pause/resume/drain controls; a {@link QueueRegistry} (held by `IngeniumApp`)
|
|
11
|
+
* indexes named queues so route handlers can enqueue from any code path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Worker function for a registered queue. Throwing causes a retry per the
|
|
16
|
+
* configured {@link RetryPolicy}; resolving means the job is `ack`'d.
|
|
17
|
+
*/
|
|
18
|
+
export type QueueWorker<TData> = (job: {
|
|
19
|
+
/** Stable id assigned by the store at enqueue time. */
|
|
20
|
+
id: string
|
|
21
|
+
/** Job payload (the value passed to `add`). */
|
|
22
|
+
data: TData
|
|
23
|
+
/**
|
|
24
|
+
* 1-indexed attempt counter. `1` on first delivery; incremented before each
|
|
25
|
+
* retry. Use this in the worker to back off external calls or short-circuit
|
|
26
|
+
* non-recoverable failures.
|
|
27
|
+
*/
|
|
28
|
+
attempt: number
|
|
29
|
+
}) => unknown | Promise<unknown>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Retry policy. The first attempt is included in the count: `attempts: 3`
|
|
33
|
+
* means one initial try + two retries.
|
|
34
|
+
*/
|
|
35
|
+
export interface RetryPolicy {
|
|
36
|
+
/** Total tries including the first delivery. Must be `>= 1`. */
|
|
37
|
+
attempts: number
|
|
38
|
+
/**
|
|
39
|
+
* Delay (ms) before the NEXT attempt, given the attempt that just failed
|
|
40
|
+
* (1-indexed). E.g. for `attempts: 3`, this is called with `1` then `2`.
|
|
41
|
+
*/
|
|
42
|
+
backoffMs: (attempt: number) => number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Options for {@link IngeniumApp.queue}. All fields optional.
|
|
47
|
+
*
|
|
48
|
+
* @typeParam TData - shape of job payloads enqueued onto this queue
|
|
49
|
+
*/
|
|
50
|
+
export interface QueueOptions<TData> {
|
|
51
|
+
/**
|
|
52
|
+
* Max concurrent jobs processed in parallel by this queue's worker pool.
|
|
53
|
+
* Default `1` (strict FIFO). Bump this for I/O-bound work.
|
|
54
|
+
*/
|
|
55
|
+
concurrency?: number
|
|
56
|
+
/**
|
|
57
|
+
* Retry policy on worker throw. Numeric shorthand `n` is equivalent to
|
|
58
|
+
* `{ attempts: n, backoffMs: exponential }`. Default: 3 attempts at
|
|
59
|
+
* 100ms / 400ms / 1.6s.
|
|
60
|
+
*/
|
|
61
|
+
retries?: number | RetryPolicy
|
|
62
|
+
/**
|
|
63
|
+
* Custom store. Default {@link MemoryQueueStore}. Implement this to back
|
|
64
|
+
* the queue with Redis / Postgres / SQS / etc.
|
|
65
|
+
*/
|
|
66
|
+
store?: QueueStore<TData>
|
|
67
|
+
/**
|
|
68
|
+
* Called once retries are exhausted, just before the job is moved to the
|
|
69
|
+
* dead-letter list. Throwing here is logged and swallowed — the job is
|
|
70
|
+
* still moved to the DLQ.
|
|
71
|
+
*/
|
|
72
|
+
onFailed?: (job: FailedJob<TData>) => void | Promise<void>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pluggable persistence layer. The default {@link MemoryQueueStore} keeps
|
|
77
|
+
* pending and failed jobs in arrays + an in-flight map. A Redis adapter
|
|
78
|
+
* would map this onto LPUSH / BRPOPLPUSH / a processing list / a DLQ list.
|
|
79
|
+
*
|
|
80
|
+
* Implementations MUST guarantee at-least-once delivery (a `next()`-ed
|
|
81
|
+
* job that is neither `ack`'d nor `retry`'d nor `fail`'d is considered
|
|
82
|
+
* stuck and may be re-delivered by the store on its own schedule).
|
|
83
|
+
*/
|
|
84
|
+
export interface QueueStore<TData> {
|
|
85
|
+
/** Append a job to the tail. Returns the assigned id. */
|
|
86
|
+
enqueue(data: TData): Promise<{ id: string }>
|
|
87
|
+
/**
|
|
88
|
+
* Pop the next pending job and move it to the in-flight set. Returns
|
|
89
|
+
* `null` when the queue is empty. The returned `attempt` reflects how
|
|
90
|
+
* many times this job has been delivered (1 on first delivery).
|
|
91
|
+
*/
|
|
92
|
+
next(): Promise<{ id: string; data: TData; attempt: number } | null>
|
|
93
|
+
/** Mark an in-flight job as completed. Removes it from the store. */
|
|
94
|
+
ack(id: string): Promise<void>
|
|
95
|
+
/**
|
|
96
|
+
* Re-enqueue the in-flight job for another attempt after `delayMs`.
|
|
97
|
+
* The store MUST increment its internal attempt counter so the next
|
|
98
|
+
* `next()` returns it with the bumped count.
|
|
99
|
+
*/
|
|
100
|
+
retry(id: string, delayMs: number): Promise<void>
|
|
101
|
+
/** Move the in-flight job to the dead-letter list. */
|
|
102
|
+
fail(id: string): Promise<void>
|
|
103
|
+
/** Number of pending (not in-flight, not failed) jobs. */
|
|
104
|
+
size(): Promise<number>
|
|
105
|
+
/** Number of jobs in the dead-letter list. */
|
|
106
|
+
failedCount(): Promise<number>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Payload passed to {@link QueueOptions.onFailed} when retries are exhausted. */
|
|
110
|
+
export interface FailedJob<TData> {
|
|
111
|
+
id: string
|
|
112
|
+
data: TData
|
|
113
|
+
/** Final attempt number (== `retries.attempts`). */
|
|
114
|
+
attempt: number
|
|
115
|
+
/** Whatever the worker threw on its last attempt. */
|
|
116
|
+
lastError: unknown
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Per-request handle returned by `ctx.queue(name)`. */
|
|
120
|
+
export interface JobHandle<TData = unknown> {
|
|
121
|
+
/** Enqueue `data`. Resolves to the assigned job id. */
|
|
122
|
+
add(data: TData): Promise<{ id: string }>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Bookkeeping wrapper a {@link QueueRegistry} keeps for every registered
|
|
127
|
+
* queue. Mostly useful for introspection / tests.
|
|
128
|
+
*/
|
|
129
|
+
export interface RegisteredQueue<TData = unknown> {
|
|
130
|
+
name: string
|
|
131
|
+
options: Required<Pick<QueueOptions<TData>, 'concurrency'>> & {
|
|
132
|
+
retries: RetryPolicy
|
|
133
|
+
onFailed: ((job: FailedJob<TData>) => void | Promise<void>) | undefined
|
|
134
|
+
}
|
|
135
|
+
}
|