@owlmeans/queue 0.1.18-rc.22 → 0.1.18-rc.24
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 +60 -12
- package/agent-meta/manifest.json +9 -2
- package/agent-meta/skills/queue/SKILL.md +54 -11
- package/agent-meta/skills/scheduled-jobs/SKILL.md +149 -0
- package/build/config.d.ts +29 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +101 -1
- package/build/config.js.map +1 -1
- package/build/errors.d.ts +9 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +13 -0
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/protocol.d.ts.map +1 -1
- package/build/protocol.js +4 -7
- package/build/protocol.js.map +1 -1
- package/build/route.d.ts +15 -0
- package/build/route.d.ts.map +1 -0
- package/build/route.js +32 -0
- package/build/route.js.map +1 -0
- package/build/transport.d.ts.map +1 -1
- package/build/transport.js +8 -11
- package/build/transport.js.map +1 -1
- package/build/types.d.ts +55 -0
- package/build/types.d.ts.map +1 -1
- package/build/worker.d.ts.map +1 -1
- package/build/worker.js +5 -4
- package/build/worker.js.map +1 -1
- package/package.json +8 -8
- package/src/config.ts +117 -2
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -0
- package/src/protocol.ts +4 -7
- package/src/route.ts +48 -0
- package/src/transport.ts +8 -11
- package/src/types.ts +56 -0
- package/src/worker.ts +5 -4
- package/tests/protocol.spec.ts +2 -2
- package/tests/schedule.spec.ts +100 -0
package/src/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { backend } from '@owlmeans/route'
|
|
2
|
+
import type { RouteDeclaration, RouteModel, RouteOptions } from '@owlmeans/route'
|
|
3
|
+
import { UnknownQueue } from './errors.js'
|
|
4
|
+
|
|
5
|
+
/** The transport identifier owned by the queue package. */
|
|
6
|
+
export const QUEUE_PROTOCOL = 'queue' as const
|
|
7
|
+
|
|
8
|
+
/** Queue-owned options stored in a generic route declaration. */
|
|
9
|
+
export interface QueueRouteOptions extends Omit<RouteOptions, 'protocol' | 'protocolOptions'> {
|
|
10
|
+
queue: string
|
|
11
|
+
reply?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Declare a backend route carried by a queue. */
|
|
15
|
+
export const job = (
|
|
16
|
+
opts: QueueRouteOptions,
|
|
17
|
+
secondary?: Omit<QueueRouteOptions, 'queue'>,
|
|
18
|
+
): Partial<RouteOptions> => {
|
|
19
|
+
const { queue, reply, ...routeOptions } = { ...opts, ...secondary }
|
|
20
|
+
const declaration = backend(routeOptions)
|
|
21
|
+
declaration.protocol = QUEUE_PROTOCOL
|
|
22
|
+
declaration.protocolOptions = { queue, ...(reply == null ? {} : { reply }) }
|
|
23
|
+
return declaration
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a declaration belongs to the queue transport. */
|
|
27
|
+
export const isQueueRoute = (route: RouteDeclaration | RouteModel): boolean =>
|
|
28
|
+
('route' in route ? route.route : route).protocol === QUEUE_PROTOCOL
|
|
29
|
+
|
|
30
|
+
/** Read and validate queue-owned route options. */
|
|
31
|
+
export const queueRouteOptions = (
|
|
32
|
+
route: RouteDeclaration | RouteModel,
|
|
33
|
+
): Pick<QueueRouteOptions, 'queue' | 'reply'> => {
|
|
34
|
+
const declaration = 'route' in route ? route.route : route
|
|
35
|
+
if (declaration.protocol !== QUEUE_PROTOCOL
|
|
36
|
+
|| declaration.protocolOptions == null
|
|
37
|
+
|| typeof declaration.protocolOptions !== 'object') {
|
|
38
|
+
throw new UnknownQueue(`${declaration.alias}: not a queue protocol`)
|
|
39
|
+
}
|
|
40
|
+
const options = declaration.protocolOptions as Record<string, unknown>
|
|
41
|
+
if (typeof options.queue !== 'string' || options.queue.trim() === '') {
|
|
42
|
+
throw new UnknownQueue(`${declaration.alias}: route declares no queue`)
|
|
43
|
+
}
|
|
44
|
+
if (options.reply != null && typeof options.reply !== 'boolean') {
|
|
45
|
+
throw new UnknownQueue(`${declaration.alias}: invalid queue reply option`)
|
|
46
|
+
}
|
|
47
|
+
return { queue: options.queue, reply: options.reply as boolean | undefined }
|
|
48
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { assertContext, createService } from '@owlmeans/context'
|
|
2
2
|
import type { AbstractRequest, AbstractResponse, EntrypointTransport } from '@owlmeans/entrypoint'
|
|
3
3
|
import { EntrypointOutcome, transportAlias } from '@owlmeans/entrypoint'
|
|
4
|
-
import { RouteProtocols } from '@owlmeans/route'
|
|
5
4
|
import { ResilientError } from '@owlmeans/error'
|
|
6
5
|
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
7
6
|
import type { Config, Context, JobEnvelope, JobReply, QueueAppend } from './types.js'
|
|
8
7
|
import { DEFAULT_JOB_TIMEOUT } from './consts.js'
|
|
9
|
-
import {
|
|
8
|
+
import { QUEUE_PROTOCOL, queueRouteOptions } from './route.js'
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* The producing half of the bridge: it turns a call on a QUEUE entrypoint into a job, and the
|
|
@@ -18,22 +17,20 @@ import { UnknownQueue } from './errors.js'
|
|
|
18
17
|
* the request rather than from the entrypoint: the far side rebuilds a request, and anything only
|
|
19
18
|
* HTTP understands would not survive the trip anyway.
|
|
20
19
|
*/
|
|
21
|
-
export const makeQueueTransport = (alias: string = transportAlias(
|
|
20
|
+
export const makeQueueTransport = (alias: string = transportAlias(QUEUE_PROTOCOL)) => {
|
|
22
21
|
const location = `queue-transport:${alias}`
|
|
23
22
|
|
|
24
23
|
const service = createService<EntrypointTransport>(alias, {
|
|
25
|
-
protocol:
|
|
24
|
+
protocol: QUEUE_PROTOCOL,
|
|
26
25
|
|
|
27
26
|
handle: (async <T>(req: AbstractRequest, res: AbstractResponse<T>) => {
|
|
28
27
|
const context = assertContext<Config, Context<Config>>(service.ctx as Context<Config>, location)
|
|
29
28
|
const entrypoint = context.entrypoint<CommonEntrypoint>(req.alias)
|
|
30
29
|
const route = entrypoint.route.route
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
throw new UnknownQueue(`${req.alias}: route declares no queue`)
|
|
34
|
-
}
|
|
31
|
+
const { queue, reply: waitsForReply } = queueRouteOptions(route)
|
|
35
32
|
|
|
36
|
-
const jobs = (context as unknown as QueueAppend).jobs<JobEnvelope, JobReply<T>>(
|
|
33
|
+
const jobs = (context as unknown as QueueAppend).jobs<JobEnvelope, JobReply<T>>(queue)
|
|
37
34
|
|
|
38
35
|
const envelope: JobEnvelope = {
|
|
39
36
|
alias: req.alias,
|
|
@@ -44,12 +41,12 @@ export const makeQueueTransport = (alias: string = transportAlias(RouteProtocols
|
|
|
44
41
|
enqueuedAt: new Date().toISOString(),
|
|
45
42
|
}
|
|
46
43
|
|
|
47
|
-
const job = await jobs.create({ queue
|
|
44
|
+
const job = await jobs.create({ queue, name: req.alias, data: envelope })
|
|
48
45
|
|
|
49
46
|
// `reply: false` says the caller is not waiting for the work, only for the promise that it
|
|
50
47
|
// was taken. `Accepted` plus the job's identity is the whole answer — enough to watch it.
|
|
51
|
-
if (
|
|
52
|
-
res.resolve({ id: job.id, queue
|
|
48
|
+
if (waitsForReply === false) {
|
|
49
|
+
res.resolve({ id: job.id, queue } as T, EntrypointOutcome.Accepted)
|
|
53
50
|
|
|
54
51
|
return
|
|
55
52
|
}
|
package/src/types.ts
CHANGED
|
@@ -20,6 +20,57 @@ export interface QueueConfig {
|
|
|
20
20
|
* check and the nonce cache, so a job delayed behind a long backlog is still admitted.
|
|
21
21
|
*/
|
|
22
22
|
envelopeTtl?: number
|
|
23
|
+
/**
|
|
24
|
+
* Recurring jobs. Declared beside the queues, in the shared package, because every process that
|
|
25
|
+
* listens to a schedule's queue reconciles the broker against this list when its worker starts —
|
|
26
|
+
* two listeners loading different lists would remove each other's schedules.
|
|
27
|
+
*/
|
|
28
|
+
schedules?: ScheduleDeclaration[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A job the broker produces on its own, on an interval or a cron pattern.
|
|
33
|
+
*
|
|
34
|
+
* A scheduled run is an ordinary job of a name the queue declares, dispatched to the processor
|
|
35
|
+
* registered for that name — never to a guarded entrypoint, because nobody's credentials travel
|
|
36
|
+
* with it. Only a process that listens to the queue creates the scheduler, and the broker produces
|
|
37
|
+
* each next run when the previous one STARTS processing — a schedule needs a running consumer.
|
|
38
|
+
*/
|
|
39
|
+
export interface ScheduleDeclaration {
|
|
40
|
+
/**
|
|
41
|
+
* The schedule's stable identity: the broker keeps one scheduler per id, so changing any other
|
|
42
|
+
* field updates it in place, while renaming the id removes one schedule and creates another.
|
|
43
|
+
*/
|
|
44
|
+
id: string
|
|
45
|
+
queue: string
|
|
46
|
+
/** A job name the queue declares. */
|
|
47
|
+
name: string
|
|
48
|
+
/**
|
|
49
|
+
* Milliseconds between runs. Exactly one of `every` and `pattern`. Without `startDate` the first
|
|
50
|
+
* run is produced as soon as the scheduler is created.
|
|
51
|
+
*/
|
|
52
|
+
every?: number
|
|
53
|
+
/** A cron pattern (an optional leading seconds field is accepted). Exactly one of the two. */
|
|
54
|
+
pattern?: string
|
|
55
|
+
/** The IANA time zone `pattern` is read in. Pattern only. */
|
|
56
|
+
tz?: string
|
|
57
|
+
startDate?: Date | number | string
|
|
58
|
+
/** Once passed, the schedule is treated as absent and a scheduler left from it is removed. */
|
|
59
|
+
endDate?: Date | number | string
|
|
60
|
+
/** Stop after this many runs. */
|
|
61
|
+
limit?: number
|
|
62
|
+
/**
|
|
63
|
+
* Run once as soon as the scheduler is created or its declaration changes, then follow the
|
|
64
|
+
* pattern. Pattern only, and never together with `startDate`. A restart that finds the
|
|
65
|
+
* declaration unchanged does not run it again.
|
|
66
|
+
*/
|
|
67
|
+
immediately?: boolean
|
|
68
|
+
data?: unknown
|
|
69
|
+
/**
|
|
70
|
+
* Options every run is enqueued with, over the queue's defaults. `id` and `delay` belong to the
|
|
71
|
+
* broker for a scheduled run and are refused.
|
|
72
|
+
*/
|
|
73
|
+
opts?: Omit<JobOptions, 'id' | 'delay'>
|
|
23
74
|
}
|
|
24
75
|
|
|
25
76
|
export interface QueueDeclaration {
|
|
@@ -152,6 +203,11 @@ export interface JobContext<D = unknown> {
|
|
|
152
203
|
attempt: number
|
|
153
204
|
data: D
|
|
154
205
|
signal: AbortSignal
|
|
206
|
+
/**
|
|
207
|
+
* The id of the schedule that produced this run (`ScheduleDeclaration.id`). Absent for a job
|
|
208
|
+
* enqueued any other way.
|
|
209
|
+
*/
|
|
210
|
+
scheduled?: string
|
|
155
211
|
/**
|
|
156
212
|
* Renew the lock. A processor that runs a long loop MUST call it as it goes: the broker judges
|
|
157
213
|
* liveness by the lock, so silence for longer than `lockDuration` is indistinguishable from a
|
package/src/worker.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Middleware } from '@owlmeans/context'
|
|
2
2
|
import { MiddlewareStage, MiddlewareType, AppType } from '@owlmeans/context'
|
|
3
3
|
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
4
|
-
import { RouteProtocols } from '@owlmeans/route'
|
|
5
4
|
import type { Config, Context, JobContext, JobEnvelope, JobProcessor, QueueWorkerService } from './types.js'
|
|
6
5
|
import { handleJob } from './bridge.js'
|
|
7
6
|
import { DEFAULT_ALIAS } from './consts.js'
|
|
7
|
+
import { isQueueRoute, queueRouteOptions } from './route.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Every queued entrypoint this process both SERVES and LISTENS to, grouped by queue.
|
|
@@ -22,17 +22,18 @@ export const servedJobs = <C extends Config, T extends Context<C>>(
|
|
|
22
22
|
|
|
23
23
|
context.entrypoints<CommonEntrypoint>().forEach(entrypoint => {
|
|
24
24
|
const route = entrypoint.route.route
|
|
25
|
-
if (route.type !== AppType.Backend || route
|
|
25
|
+
if (route.type !== AppType.Backend || !isQueueRoute(route)) {
|
|
26
26
|
return
|
|
27
27
|
}
|
|
28
28
|
if (route.service != null && route.service !== context.cfg.service) {
|
|
29
29
|
return
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
const { queue } = queueRouteOptions(route)
|
|
32
|
+
if (entrypoint.handle == null || !listen.includes(queue)) {
|
|
32
33
|
return
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
served.set(
|
|
36
|
+
served.set(queue, [...(served.get(queue) ?? []), entrypoint])
|
|
36
37
|
})
|
|
37
38
|
|
|
38
39
|
return served
|
package/tests/protocol.spec.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { contract, protocol, schema } from '@owlmeans/entrypoint'
|
|
3
|
-
import {
|
|
3
|
+
import { route } from '@owlmeans/route'
|
|
4
4
|
import type { JSONSchemaType } from 'ajv'
|
|
5
|
-
import { enqueueProtocol, waitForProtocol, type JobRecord } from '../src/index.js'
|
|
5
|
+
import { enqueueProtocol, job, waitForProtocol, type JobRecord } from '../src/index.js'
|
|
6
6
|
|
|
7
7
|
interface Input { value: number }
|
|
8
8
|
interface Output { doubled: number }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
assertSchedule, assertSchedules, declareQueue, declareSchedule, queueOf, schedulesOf
|
|
4
|
+
} from '../src/config.js'
|
|
5
|
+
import { ScheduleMisdeclared, UnknownJobName, UnknownQueue } from '../src/errors.js'
|
|
6
|
+
import type { Config, ScheduleDeclaration } from '../src/types.js'
|
|
7
|
+
|
|
8
|
+
const cfg = (): Config => declareQueue(
|
|
9
|
+
declareQueue({ ready: false, service: 'test', type: 'backend' } as unknown as Config,
|
|
10
|
+
'maintenance', ['reconcile', 'sweep']),
|
|
11
|
+
'work', ['story:code']
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const nightly: ScheduleDeclaration = {
|
|
15
|
+
id: 'nightly-reconcile', queue: 'maintenance', name: 'reconcile', pattern: '17 3 * * *', tz: 'UTC',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const misdeclared = (schedule: Partial<ScheduleDeclaration>): () => void =>
|
|
19
|
+
() => assertSchedule(cfg(), { ...nightly, ...schedule } as ScheduleDeclaration)
|
|
20
|
+
|
|
21
|
+
describe('declaring schedules', () => {
|
|
22
|
+
test('a declaration is recorded and read back by its queue', () => {
|
|
23
|
+
const config = declareSchedule(cfg(), nightly)
|
|
24
|
+
|
|
25
|
+
expect(schedulesOf(config, 'maintenance')).toEqual([nightly])
|
|
26
|
+
expect(schedulesOf(config, 'work')).toEqual([])
|
|
27
|
+
expect(schedulesOf(config)).toHaveLength(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('re-declaring an id replaces rather than appends', () => {
|
|
31
|
+
let config = declareSchedule(cfg(), nightly)
|
|
32
|
+
config = declareSchedule(config, { ...nightly, pattern: '0 4 * * *' })
|
|
33
|
+
|
|
34
|
+
expect(schedulesOf(config)).toHaveLength(1)
|
|
35
|
+
expect(schedulesOf(config)[0].pattern).toBe('0 4 * * *')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A scheduled run of a name the queue does not accept would fail on every tick. Refusing at
|
|
40
|
+
* declaration turns that into one error at boot, and the refused schedule is not recorded.
|
|
41
|
+
*/
|
|
42
|
+
test('a schedule for an undeclared queue or job name is refused and not recorded', () => {
|
|
43
|
+
const config = cfg()
|
|
44
|
+
|
|
45
|
+
expect(() => declareSchedule(config, { ...nightly, queue: 'missing' })).toThrow(UnknownQueue)
|
|
46
|
+
expect(() => declareSchedule(config, { ...nightly, name: 'story:code' })).toThrow(UnknownJobName)
|
|
47
|
+
expect(schedulesOf(config)).toHaveLength(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('re-declaring the queue keeps its schedules', () => {
|
|
51
|
+
const config = declareQueue(declareSchedule(cfg(), nightly), 'maintenance', ['reconcile'])
|
|
52
|
+
|
|
53
|
+
expect(queueOf(config, 'maintenance').jobs).toEqual(['reconcile'])
|
|
54
|
+
expect(schedulesOf(config)).toEqual([nightly])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('asserting a schedule', () => {
|
|
59
|
+
test('exactly one of every and pattern', () => {
|
|
60
|
+
expect(misdeclared({ every: 1_000 })).toThrow(/every-or-pattern/)
|
|
61
|
+
expect(misdeclared({ pattern: undefined, tz: undefined })).toThrow(/every-or-pattern/)
|
|
62
|
+
expect(misdeclared({ pattern: undefined, tz: undefined, every: 0 })).toThrow(/:every$/)
|
|
63
|
+
expect(misdeclared({ pattern: undefined, tz: undefined, every: 1.5 })).toThrow(ScheduleMisdeclared)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('tz and immediately belong to a pattern only', () => {
|
|
67
|
+
expect(misdeclared({ pattern: undefined, every: 1_000 })).toThrow(/:tz$/)
|
|
68
|
+
expect(misdeclared({ pattern: undefined, tz: undefined, every: 1_000, immediately: true }))
|
|
69
|
+
.toThrow(/:immediately$/)
|
|
70
|
+
expect(misdeclared({ immediately: true, startDate: new Date() })).toThrow(/immediately-start-date/)
|
|
71
|
+
expect(misdeclared({ immediately: true })).not.toThrow()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('the broker owns a scheduled run\'s id and delay', () => {
|
|
75
|
+
expect(misdeclared({ opts: { id: 'fixed' } as ScheduleDeclaration['opts'] })).toThrow(/opts-id/)
|
|
76
|
+
expect(misdeclared({ opts: { delay: 10 } as ScheduleDeclaration['opts'] })).toThrow(/opts-delay/)
|
|
77
|
+
expect(misdeclared({ opts: { attempts: 3 } })).not.toThrow()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('an empty id is refused', () => {
|
|
81
|
+
expect(misdeclared({ id: ' ' })).toThrow(ScheduleMisdeclared)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('asserting every schedule', () => {
|
|
86
|
+
test('two schedules under one id are refused', () => {
|
|
87
|
+
const config = declareSchedule(cfg(), nightly)
|
|
88
|
+
config.queue?.schedules?.push({ ...nightly, pattern: '0 5 * * *' })
|
|
89
|
+
|
|
90
|
+
expect(() => assertSchedules(config)).toThrow(/duplicate/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/** The configuration is plain data; a schedule written into it directly is checked too. */
|
|
94
|
+
test('a schedule written past declareSchedule is still checked', () => {
|
|
95
|
+
const config = declareSchedule(cfg(), nightly)
|
|
96
|
+
config.queue?.schedules?.push({ id: 'orphan', queue: 'maintenance', name: 'gone', every: 1_000 })
|
|
97
|
+
|
|
98
|
+
expect(() => assertSchedules(config)).toThrow(UnknownJobName)
|
|
99
|
+
})
|
|
100
|
+
})
|