@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/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 { UnknownQueue } from './errors.js'
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(RouteProtocols.QUEUE)) => {
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: RouteProtocols.QUEUE,
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
- if (route.queue == null) {
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>>(route.queue)
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: route.queue, name: req.alias, data: envelope })
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 (route.reply === false) {
52
- res.resolve({ id: job.id, queue: route.queue } as T, EntrypointOutcome.Accepted)
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.protocol !== RouteProtocols.QUEUE) {
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
- if (entrypoint.handle == null || route.queue == null || !listen.includes(route.queue)) {
31
+ const { queue } = queueRouteOptions(route)
32
+ if (entrypoint.handle == null || !listen.includes(queue)) {
32
33
  return
33
34
  }
34
35
 
35
- served.set(route.queue, [...(served.get(route.queue) ?? []), entrypoint])
36
+ served.set(queue, [...(served.get(queue) ?? []), entrypoint])
36
37
  })
37
38
 
38
39
  return served
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { contract, protocol, schema } from '@owlmeans/entrypoint'
3
- import { job, route } from '@owlmeans/route'
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
+ })