@owlmeans/queue 0.1.18-rc.17 → 0.1.18-rc.19

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 CHANGED
@@ -1,129 +1,249 @@
1
1
  # @owlmeans/queue
2
2
 
3
- Job queues as resources, and the route protocol that lets a queued call look like any other call.
4
-
5
- ## Overview
6
-
7
- This package is contracts only — it carries no broker code. A driver implements them
8
- (`@owlmeans/redis-queue` does, over BullMQ); depend on this package from a shared contract
9
- package, and on the driver only where the application wires itself up.
10
-
11
- The point of it: a route that names `RouteProtocols.QUEUE` is taken by the queue transport, so the
12
- caller writes `ep.call(...)` exactly as it would for HTTP. Moving a service-to-service call onto
13
- the broker is a change to one declaration rather than to every call site, and a call that used to
14
- die with the process survives a restart.
3
+ Job queues as resources, plus the route protocol that makes a queued call look like any other call.
4
+ Use it when work must outlive the request. Three cases qualify: the user would otherwise wait more
5
+ than about five seconds, accepted work must survive a process restart, or the work is retried
6
+ against a third party (payment capture, provider sync, bulk mail, report generation, model
7
+ pipelines). A request that finishes in under about two seconds and makes no external call should
8
+ not queue: do it inline. Check for a smaller fix first, such as an index, a batch write or a cached
9
+ aggregate. The framework has no scheduler, so recurring work is triggered by a platform cron that
10
+ calls an entrypoint or enqueues a job.
11
+
12
+ This package is contracts only and carries no broker code. A driver implements them
13
+ (`@owlmeans/redis-queue` does, over BullMQ). Depend on this package from a shared contract package,
14
+ and on the driver only where the application wires itself up.
15
15
 
16
16
  ## Installation
17
17
 
18
18
  ```bash
19
- bun add @owlmeans/queue@^0.1.18-rc.8
19
+ bun add @owlmeans/queue@^0.1.18-rc.18
20
20
  ```
21
21
 
22
- ## Declaring queues
23
-
24
- Queues and the job names they accept belong in the shared backend package, so producer and
25
- consumer read the same list:
26
-
27
- ```typescript
28
- import { declareQueue, listenQueues } from '@owlmeans/queue'
22
+ ## Concepts
23
+
24
+ - **Queue declaration**: `declareQueue(cfg, name, jobs, opts?)` in the shared backend config. It
25
+ names a queue, the job names it accepts, and its worker and default job options. It is an
26
+ address, not a deployment.
27
+ - **Listen**: `listenQueues(cfg, ...names)` in one process's own config says which queues *that
28
+ process* consumes. A process that listens to nothing is a producer only.
29
+ - **Queued protocol**: an immutable entrypoint protocol whose route uses `job()` instead of
30
+ `backend()` (`RouteProtocols.QUEUE`). Callers use `ctx.entrypoint(protocol).call(...)` unchanged,
31
+ and the QUEUE transport carries the call through the broker.
32
+ - **Job record**: `ctx.jobs(queue)` returns a `QueueResource`, the ordinary `Resource<JobRecord>`
33
+ contract plus `wait`, `flow`, `counts` and `close`. `create` enqueues, `list` inspects and `take`
34
+ cancels.
35
+ - **Processor**: a function registered with `QueueWorkerService.process(queue, name, fn)` that gets
36
+ a `JobContext`. It must be safe to run twice and must `touch()` in long loops.
37
+ - **Single-flight id**: a `JobOptions.id` derived from what the job is about (`develop:<storyId>`).
38
+ A duplicate enqueue then returns the existing job instead of a second one.
39
+
40
+ ## Usage
41
+
42
+ ### Declare queues in the shared backend config
43
+
44
+ ```ts
45
+ import { declareQueue } from '@owlmeans/queue'
46
+
47
+ // Split lanes by how long work takes, not by topic: a one-second read queued behind
48
+ // a thirty-minute job is a UI that looks broken.
49
+ declareQueue(cfg, APP_OPS, [appProtocols.files.get.alias, appProtocols.report.build.alias], {
50
+ worker: { concurrency: 16, lockDuration: 30_000 }
51
+ })
29
52
 
30
- declareQueue(cfg, AGENT_WORK, ['agent:story:develop', 'agent:story:code'], {
31
- worker: { concurrency: 3, lockDuration: 60_000 }
53
+ declareQueue(cfg, APP_BILLING, [appProtocols.invoice.capture.alias], {
54
+ worker: { concurrency: 4, lockDuration: 30_000 },
55
+ defaults: { attempts: 3, backoff: { type: 'exponential', delay: 250 } }
32
56
  })
33
57
  ```
34
58
 
35
- Which queues a given process consumes is a separate, process-local statement:
59
+ The worker process names what it consumes, in its own config:
36
60
 
37
- ```typescript
38
- listenQueues(cfg, AGENT_WORK, AGENT_OPS) // absent ⇒ this process only produces
61
+ ```ts
62
+ import { listenQueues } from '@owlmeans/queue'
63
+
64
+ listenQueues(cfg, APP_OPS, APP_BILLING) // the api process omits this and only produces
39
65
  ```
40
66
 
41
- A declaration is an address; `listen` is a deployment fact. Keeping them apart is what lets the
42
- same binary run as a producer in one deployment and a worker in another.
67
+ Both processes build their context through the same backend factory, which wires the driver once:
68
+
69
+ ```ts
70
+ import { appendRedisQueue } from '@owlmeans/redis-queue'
43
71
 
44
- ## Declaring a queued entrypoint
72
+ appendRedis<C, T>(context)
73
+ appendRedisQueue(context, { hooks: billingQueueHooks(context) })
74
+ ```
45
75
 
46
- ```typescript
76
+ ### Declare a queued entrypoint
77
+
78
+ ```ts
47
79
  import { contract, protocol, typed } from '@owlmeans/entrypoint'
48
- import { job } from '@owlmeans/route'
80
+ import { backend, job, route } from '@owlmeans/route'
81
+
82
+ const aliases = { base: 'app:report', build: 'app:report:build' } as const
83
+ const reportBase = protocol(route(aliases.base, '/reports', backend({ service: APP_WORKER })), contract())
84
+
85
+ export const appProtocols = {
86
+ report: {
87
+ base: reportBase,
88
+ build: protocol(
89
+ route(aliases.build, '/:id/build',
90
+ job({ parent: reportBase, service: APP_WORKER, queue: APP_OPS, timeout: 30_000 })),
91
+ contract.request({ params: typed<ReportParams>(ReportParamsSchema) }, typed<ReportResult>()),
92
+ ),
93
+ },
94
+ } as const
95
+ ```
49
96
 
50
- protocol(
51
- route(agent.story.develop, '/:id/develop',
52
- job({ parent: agent.story.base, service: AGENT, queue: AGENT_WORK, timeout: 30_000 })),
53
- contract.request({ params: typed<StoryParams>(StoryParamsSchema) }, typed()),
54
- )
97
+ The worker process serves it like any backend entrypoint; the HTTP server skips QUEUE routes. A
98
+ producer calls it with no knowledge of the broker:
99
+
100
+ ```ts
101
+ const result = await context.entrypoint(appProtocols.report.build).call({ params: { id } })
55
102
  ```
56
103
 
57
- It is served by binding its shared job protocol and called with `call()` like any backend entrypoint. Add
58
- `reply: false` to return as soon as the job is accepted — the call resolves `Accepted` with
59
- `{ id, queue }`, which is what a long pipeline wants.
104
+ Add `reply: false` to the `job()` options to resolve `Accepted` with `{ id, queue }` as soon as the
105
+ broker has taken the job.
106
+
107
+ ### Enqueue with broker options and wait
60
108
 
61
- ## Jobs are records
109
+ When a producer needs a job id, a delay or retries, pass the same protocol object to the typed
110
+ helpers. Never pass its alias.
62
111
 
63
- ```typescript
64
- const jobs = ctx.jobs<DevelopInput, DevelopResult>(AGENT_WORK)
112
+ ```ts
113
+ import { enqueueProtocol, waitForProtocol } from '@owlmeans/queue'
65
114
 
66
- const queued = await jobs.create({ queue: AGENT_WORK, name: 'agent:story:code', data: input })
67
- const result = await jobs.wait(queued.id, { timeout: 120_000 })
115
+ const queued = await enqueueProtocol(context, appProtocols.invoice.capture, {
116
+ body: { invoiceId },
117
+ }, {
118
+ id: `capture:${invoiceId}:${attemptSequence}`, // derived from what the job is about
119
+ delay: 250, attempts: 3, backoff: { type: 'exponential', delay: 250 },
120
+ })
68
121
 
69
- const failed = await jobs.list({ state: JobState.Failed })
70
- await jobs.take(queued.id) // cancel and return
122
+ const receipt = await waitForProtocol(context, appProtocols.invoice.capture, queued, { timeout: 120_000 })
71
123
  ```
72
124
 
73
- `QueueResource<D, R>` is a `Resource<JobRecord<D, R>>` composed with `PubSubResource<JobEvent<R>>`,
74
- so criteria, sorting and paging behave as they do for every other backend. Only what the base
75
- contract cannot express is added: `wait`, `flow` and `counts`.
125
+ `queueJobOf(req)` gives a protocol handler `{ id, name, queue, attempt, touch }` when it must compare
126
+ the broker job with a claim it persisted.
76
127
 
77
- Deriving `JobOptions.id` from what the job is about (`develop:<storyId>`) makes a duplicate
78
- enqueue a no-op, which is what makes an admission step safe to retry.
128
+ ### Processors, hooks and job graphs
79
129
 
80
- ## Processors
130
+ ```ts
131
+ import { DEFAULT_ALIAS } from '@owlmeans/queue'
132
+ import type { QueueWorkerService } from '@owlmeans/queue'
81
133
 
82
- ```typescript
83
- worker.process(AGENT_WORK, 'agent:story:code', async job => {
84
- for (const screen of screens) {
85
- await job.touch()
86
- await develop(screen)
134
+ const worker = context.service<QueueWorkerService>(DEFAULT_ALIAS)
135
+
136
+ worker.process(APP_OPS, 'app:import:rows', async job => {
137
+ for (const chunk of chunks(job.data.fileId)) {
138
+ await job.touch() // renew the lock on every iteration
139
+ await importChunk(chunk) // idempotent: skips rows already imported
87
140
  }
88
141
  return { ok: true }
89
142
  })
90
- ```
91
-
92
- `touch()` is not optional in a long loop. The broker judges liveness by the lock, so going quiet
93
- for longer than `lockDuration` is indistinguishable from a dead worker and the job is re-run
94
- elsewhere while this one is still going.
95
143
 
96
- A child job reports a domain failure by RETURNING `{ ok: false, error }`. Throwing means
97
- "infrastructure broke, retry me". Parents read `children()` and decide — children never fail
98
- their parent directly.
99
-
100
- Retries default to a single attempt: this work mostly talks to a model, and a blind retry
101
- re-spends the tokens that just failed.
102
-
103
- ## Job graphs
144
+ worker.hooks({
145
+ onJobDead: async (record, reason) => { // retries exhausted: compensate
146
+ await imports.patch({ id: record.data.importId, status: 'failed', error: reason })
147
+ }
148
+ })
104
149
 
105
- ```typescript
106
- await jobs.flow({
107
- name: 'story:finalize', data: ref, children: [
108
- { name: 'story:gate', data: ref, children: [{ name: 'story:code', data: ref }] }
150
+ // children complete before the parent; siblings run in parallel
151
+ await context.jobs(APP_OPS).flow({
152
+ name: 'app:import:finalize', data: ref, children: [
153
+ { name: 'app:import:validate', data: ref, children: [{ name: 'app:import:rows', data: ref }] }
109
154
  ]
110
155
  })
111
156
  ```
112
157
 
113
- Nesting is sequence, siblings are parallel, and a parent reading `children()` is the join.
158
+ ### Jobs are records
114
159
 
115
- ## Errors
160
+ ```ts
161
+ import { JobState } from '@owlmeans/queue'
116
162
 
117
- A refusal the caller must recognise travels in the reply and is rebuilt on the producing side as
118
- its own class, so `instanceof` keeps working across the broker. `QueueTimeout` means the WAIT
119
- ended, not the job — read the job back to learn what became of it.
163
+ const jobs = context.jobs<ImportInput, ImportResult>(APP_OPS)
120
164
 
121
- ## Related Packages
165
+ const failed = await jobs.list({ state: JobState.Failed }, { size: 20 })
166
+ const counts = await jobs.counts()
167
+ await jobs.take(jobId) // cancel and return
168
+ ```
122
169
 
123
- - [`@owlmeans/redis-queue`](../redis-queue) — the BullMQ driver, and where queue integration tests live
124
- - [`@owlmeans/route`](../route) — `RouteProtocols.QUEUE` and the `job()` builder
125
- - [`@owlmeans/entrypoint`](../entrypoint) — declarations and the transport seam
126
- - [`@owlmeans/resource`](../resource) — the `Resource` contract jobs are read through
170
+ ## Processor rules
171
+
172
+ - **`touch()` in every long loop.** The broker judges liveness by the lock. Silence longer than
173
+ `lockDuration` looks like a dead worker, so the job is re-run elsewhere while this one is still
174
+ going.
175
+ - **Children report, parents decide.** A child returns `{ ok: false, error }` for a domain failure.
176
+ Throwing means "infrastructure broke, retry me". Parents read `children()` / `failedChildren()`.
177
+ - **Retries default to one attempt** (`DEFAULT_ATTEMPTS`), because a blind retry of model work
178
+ re-spends the tokens that just failed. Raise `attempts` per queue for cheap idempotent work.
179
+ - **`job.signal` is not an interrupt.** An orderly stop closes workers first and waits for jobs in
180
+ flight to return.
181
+
182
+ ## Guards and errors
183
+
184
+ `handleJob` rebuilds the request from the envelope and runs the entrypoint's guards the way the
185
+ HTTP boundary does, attaching the entity. A guarded queued entrypoint therefore needs the
186
+ producer's credentials in the envelope `headers`. `cfg.queue.envelopeTtl` (seconds) bounds how long
187
+ after `enqueuedAt` an envelope is still accepted; past it, the job answers `EnvelopeExpired`.
188
+
189
+ A refusal the caller must recognise is carried in the reply and rebuilt on the producing side as its
190
+ own class, so `instanceof` keeps working across the broker. `QueueTimeout` means the WAIT ended,
191
+ not the job, so read the job back to learn what became of it.
192
+
193
+ ## API
194
+
195
+ | Symbol | Kind | Purpose |
196
+ |---|---|---|
197
+ | `declareQueue(cfg, name, jobs, opts?)` | function | Declare a queue and the job names it accepts; re-declaring replaces |
198
+ | `listenQueues(cfg, ...names)` | function | Name the queues this process consumes |
199
+ | `queueOf(cfg, name)`, `queueOfJob(cfg, job)`, `isListening(cfg, name)` | function | Read declarations back; `queueOf` throws `UnknownQueue` |
200
+ | `enqueueProtocol(ctx, protocol, request, options?)` | function | Enqueue a QUEUE protocol with `JobOptions`, typed by the protocol |
201
+ | `waitForProtocol(ctx, protocol, job, { timeout }?)` | function | Wait for that job and unwrap the typed reply |
202
+ | `queueJobOf(request)` | function | `QueueJobMeta` of the broker job behind a handler's request, or `null` |
203
+ | `appendQueueTransport(ctx, alias?)`, `makeQueueTransport(alias?)` | function | Register the QUEUE transport so `call()` routes through the broker |
204
+ | `queueWorkerMiddleware(alias?)` | function | Start the worker at the Ready stage, only in a process that listens |
205
+ | `handleJob(ctx, job)`, `entrypointProcessor(ctx)`, `servedJobs(ctx)` | function | The bridge a driver dispatches entrypoint jobs through |
206
+ | `requestOf(envelope)`, `assertFresh(envelope, ttl?)` | function | Rebuild a request from an envelope; enforce `envelopeTtl` |
207
+ | `QueueConfig`, `QueueDeclaration`, `QueueWorkerOptions`, `JobOptions` | type | Configuration shapes |
208
+ | `QueueResource<D, R>` | type | A queue as a resource, plus `queue`, `wait`, `flow`, `counts`, `close` |
209
+ | `JobRecord<D, R>`, `JobEvent<R>`, `FlowSpec<D>` | type | A job as a record, a lifecycle event, a graph node |
210
+ | `QueueWorkerService` | type | `process`, `start`, `stop`, `listening`, `hooks` |
211
+ | `JobContext<D>`, `JobProcessor<D, R>` | type | What a processor receives; the processor signature |
212
+ | `QueueHooks` | type | `wrapHandler`, `onJobResult`, `onJobStalled`, `onJobDead` |
213
+ | `QueueJobMeta`, `JobEnvelope`, `JobReply<T>` | type | Broker identity for a handler, the call envelope, the reply shape |
214
+ | `QueueAppend`, `QueueDriver` | type | The `ctx.jobs(queue?)` mixin a driver installs; what a driver supplies |
215
+ | `Config`, `Context`, `QueueTransportRequest`, `QueueTransportResponse` | type | Context, config and transport types |
216
+ | `JobState`, `JobEventType` | enum | Job lifecycle states and event types |
217
+ | `isSettled(state?)` | function | Whether a state is final |
218
+ | `DEFAULT_ALIAS`, `DEFAULT_JOB_TIMEOUT`, `DEFAULT_ATTEMPTS` | const | `'queue'`, `60_000` ms, `1` |
219
+ | `QueueError`, `QueueTimeout`, `UnknownJob`, `UnknownJobName`, `UnknownQueue`, `QueueNotListening`, `JobNotServed`, `EnvelopeExpired` | class | Error family |
220
+
221
+ ## Common pitfalls
222
+
223
+ - **Queueing work the request could finish.** It adds a second process to deploy and a place to fail.
224
+ - **Passing alias strings to `enqueueProtocol`/`waitForProtocol`.** They take the protocol object and
225
+ reject strings and non-QUEUE declarations.
226
+ - **Enqueueing a job name the queue did not declare.** It throws `UnknownJobName` rather than
227
+ parking a job nothing can process.
228
+ - **Registering a processor in a process that does not `listen`.** It throws `QueueNotListening`.
229
+ - **Putting `listen` in the shared declaration.** Every deployment of that binary would then
230
+ consume everything.
231
+ - **Random job ids on an admission step.** Derive the id from the subject, or a retried request
232
+ enqueues twice.
233
+ - **Long loops without `touch()`**, or processors that are not safe to run twice.
234
+ - **Throwing from a child for a domain failure.** Return `{ ok: false, error }` instead.
235
+ - **Equal child ids across queues in one flow.** `children()` is keyed by bare id and collapses them.
236
+ - **A self-re-enqueueing job as a cron.** Use a platform `CronJob`. `delay` defers a single job once.
237
+ - **Calling `ctx.jobs()` with no name once a second queue exists.** It throws `UnknownQueue`.
238
+
239
+ ## Related packages
240
+
241
+ - [`@owlmeans/redis-queue`](../redis-queue): the BullMQ driver, and where queue integration tests live
242
+ - [`@owlmeans/route`](../route): `RouteProtocols.QUEUE` and the `job()` builder
243
+ - [`@owlmeans/entrypoint`](../entrypoint): protocol declarations and the transport seam
244
+ - [`@owlmeans/resource`](../resource): the `Resource` contract jobs are read through
245
+ - [`@owlmeans/server-job`](../server-job) / [`@owlmeans/client-job`](../client-job): listing,
246
+ cancelling and following jobs from an application's UI
127
247
 
128
248
  <!-- owlmeans:agent-guidance:start -->
129
249
  ## Agent guidance
@@ -133,7 +253,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
133
253
  your project's skill store (`.agents/skills/`):
134
254
 
135
255
  ```sh
136
- npx @owlmeans/agent-skills@^0.1.18-rc.20
256
+ npx @owlmeans/agent-skills@^0.1.18-rc.22
137
257
  ```
138
258
 
139
259
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/queue",
4
- "version": "0.1.18-rc.17",
5
- "generatedAt": "2026-09-12T14:18:54.799Z",
4
+ "version": "0.1.18-rc.19",
5
+ "generatedAt": "2026-09-15T12:38:15.241Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/queue
9
9
 
10
10
  **Layer:** Infra
11
- **Install:** `"@owlmeans/queue": "^0.1.18-rc.17"` in `dependencies`
11
+ **Install:** `"@owlmeans/queue": "^0.1.18-rc.19"` in `dependencies`
12
12
 
13
13
  Contracts only. It carries no broker code — a driver package (`@owlmeans/redis-queue`) implements
14
14
  them. Depend on this one from a shared contract package; depend on the driver only where the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/queue",
3
- "version": "0.1.18-rc.17",
3
+ "version": "0.1.18-rc.19",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -31,12 +31,12 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@owlmeans/auth": "^0.1.18-rc.17",
35
- "@owlmeans/auth-common": "^0.1.18-rc.21",
36
- "@owlmeans/context": "^0.1.18-rc.16",
37
- "@owlmeans/entrypoint": "^0.1.18-rc.19",
38
- "@owlmeans/error": "^0.1.18-rc.16",
39
- "@owlmeans/resource": "^0.1.18-rc.17",
40
- "@owlmeans/route": "^0.1.18-rc.17"
34
+ "@owlmeans/auth": "^0.1.18-rc.19",
35
+ "@owlmeans/auth-common": "^0.1.18-rc.23",
36
+ "@owlmeans/context": "^0.1.18-rc.18",
37
+ "@owlmeans/entrypoint": "^0.1.18-rc.21",
38
+ "@owlmeans/error": "^0.1.18-rc.18",
39
+ "@owlmeans/resource": "^0.1.18-rc.19",
40
+ "@owlmeans/route": "^0.1.18-rc.19"
41
41
  }
42
42
  }