Skip to content

Commit b11a883

Browse files
authored
Merge pull request #746 from bcomnes/minPriority
Implement minPriority and maxPriority jobFetch options
2 parents 92408e1 + 7ed2ea6 commit b11a883

8 files changed

Lines changed: 183 additions & 7 deletions

File tree

docs/api/jobs.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,14 @@ Returns an array of jobs from a queue
227227

228228
If `true`, jobs with a `startAfter` timestamp in the future will be fetched. Useful for fetching jobs immediately without waiting for a retry delay.
229229

230+
* `minPriority`, int
231+
232+
If set, only fetch jobs with a priority greater than or equal to this value. If used together with `maxPriority`, `minPriority` must be less than or equal to `maxPriority`.
233+
234+
* `maxPriority`, int
235+
236+
If set, only fetch jobs with a priority less than or equal to this value. If used together with `minPriority`, `minPriority` must be less than or equal to `maxPriority`.
237+
230238
```js
231239
interface JobWithMetadata<T = object> {
232240
id: string;

docs/api/workers.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ The default options for `work()` is 1 job every 2 seconds.
3333

3434
Same as in [`fetch()`](#fetch)
3535

36+
* **minPriority**, int
37+
38+
Same as in [`fetch()`](#fetch)
39+
40+
* **maxPriority**, int
41+
42+
Same as in [`fetch()`](#fetch)
43+
3644
* **pollingIntervalSeconds**, int, *(default=2)*
3745

3846
Interval to check for new jobs in seconds, must be >=0.5 (500ms)

packages/proxy/src/contracts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ export const fetchOptionsSchema = z.object({
7979
ignoreStartAfter: z.boolean().optional(),
8080
groupConcurrency: z.union([z.number(), groupConcurrencyConfigSchema]).optional(),
8181
ignoreGroups: z.array(z.string()).nullable().optional(),
82-
}) satisfies z.ZodType<types.HttpFetchOptions>
82+
minPriority: z.number().int().optional(),
83+
maxPriority: z.number().int().optional(),
84+
}).refine(
85+
data => data.minPriority == null || data.maxPriority == null || data.minPriority <= data.maxPriority,
86+
{ message: 'minPriority must be <= maxPriority' }
87+
) satisfies z.ZodType<types.HttpFetchOptions>
8388

8489
export const findJobsOptionsSchema = z.object({
8590
id: z.string().optional(),

src/attorney.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ function validateGroupConcurrencyValue (value: any, optionName: string) {
101101
}
102102
}
103103

104+
function validatePriorityRangeConfig (config: any) {
105+
if (config.minPriority !== undefined) {
106+
assert(Number.isInteger(config.minPriority), 'minPriority must be an integer')
107+
}
108+
if (config.maxPriority !== undefined) {
109+
assert(Number.isInteger(config.maxPriority), 'maxPriority must be an integer')
110+
}
111+
if (config.minPriority !== undefined && config.maxPriority !== undefined) {
112+
assert(config.minPriority <= config.maxPriority, 'minPriority must be <= maxPriority')
113+
}
114+
}
115+
104116
function validateGroupConcurrencyConfig (config: any) {
105117
const hasGlobal = config.groupConcurrency != null
106118
const hasLocal = config.localGroupConcurrency != null
@@ -168,6 +180,7 @@ function checkWorkArgs (name: string, args: any[]): {
168180
assert(!('includeMetadata' in options) || typeof options.includeMetadata === 'boolean', 'includeMetadata must be a boolean')
169181
assert(!('priority' in options) || typeof options.priority === 'boolean', 'priority must be a boolean')
170182
assert(!('localConcurrency' in options) || (Number.isInteger(options.localConcurrency) && options.localConcurrency >= 1), 'localConcurrency must be an integer >= 1')
183+
validatePriorityRangeConfig(options)
171184
validateGroupConcurrencyConfig(options)
172185
validateHeartbeatRefreshConfig(options)
173186

@@ -181,6 +194,7 @@ function checkFetchArgs (name: string, options: any) {
181194
assert(!('includeMetadata' in options) || typeof options.includeMetadata === 'boolean', 'includeMetadata must be a boolean')
182195
assert(!('priority' in options) || typeof options.priority === 'boolean', 'priority must be a boolean')
183196
assert(!('ignoreStartAfter' in options) || typeof options.ignoreStartAfter === 'boolean', 'ignoreStartAfter must be a boolean')
197+
validatePriorityRangeConfig(options)
184198
}
185199

186200
function getConfig (value: string | types.ConstructorOptions): types.ResolvedConstructorOptions {

src/manager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,9 @@ class Manager extends EventEmitter implements types.EventsMixin {
327327
localGroupConcurrency,
328328
groupConcurrency,
329329
orderByCreatedOn = true,
330-
heartbeatRefreshSeconds
330+
heartbeatRefreshSeconds,
331+
minPriority,
332+
maxPriority,
331333
} = options
332334

333335
if (localGroupConcurrency != null) {
@@ -341,7 +343,7 @@ class Manager extends EventEmitter implements types.EventsMixin {
341343
const ignoreGroups = localGroupConcurrency != null
342344
? this.#getGroupsAtLocalCapacity(name)
343345
: undefined
344-
return this.fetch<ReqData>(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups })
346+
return this.fetch<ReqData>(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups, minPriority, maxPriority })
345347
}
346348

347349
const onFetch = async (jobs: types.Job<ReqData>[]) => {

src/plans.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,8 @@ interface FetchJobOptions {
795795
ignoreSingletons: string[] | null
796796
ignoreGroups?: string[] | null
797797
groupConcurrency?: number | GroupConcurrencyConfig
798+
minPriority?: number
799+
maxPriority?: number
798800
}
799801

800802
interface FetchQueryParams {
@@ -803,13 +805,17 @@ interface FetchQueryParams {
803805
ignoreGroupsParam: string
804806
defaultGroupLimitParam: string
805807
tiersParam: string
808+
minPriorityParam: string
809+
maxPriorityParam: string
806810
}
807811

808812
function buildFetchParams (options: FetchJobOptions): FetchQueryParams {
809-
const { ignoreSingletons, ignoreGroups, groupConcurrency } = options
813+
const { ignoreSingletons, ignoreGroups, groupConcurrency, minPriority, maxPriority } = options
810814
const hasIgnoreSingletons = ignoreSingletons != null && ignoreSingletons.length > 0
811815
const hasIgnoreGroups = ignoreGroups != null && ignoreGroups.length > 0
812816
const hasGroupConcurrency = groupConcurrency != null
817+
const hasMinPriority = minPriority != null
818+
const hasMaxPriority = maxPriority != null
813819
const groupConcurrencyConfig = hasGroupConcurrency
814820
? (typeof groupConcurrency === 'number' ? { default: groupConcurrency } : groupConcurrency)
815821
: null
@@ -821,6 +827,8 @@ function buildFetchParams (options: FetchJobOptions): FetchQueryParams {
821827
let ignoreGroupsParam = ''
822828
let defaultGroupLimitParam = ''
823829
let tiersParam = ''
830+
let minPriorityParam = ''
831+
let maxPriorityParam = ''
824832

825833
if (hasIgnoreSingletons) {
826834
paramIndex++
@@ -846,16 +854,30 @@ function buildFetchParams (options: FetchJobOptions): FetchQueryParams {
846854
}
847855
}
848856

849-
return { values, ignoreSingletonsParam, ignoreGroupsParam, defaultGroupLimitParam, tiersParam }
857+
if (hasMinPriority) {
858+
paramIndex++
859+
minPriorityParam = `$${paramIndex}::int`
860+
values.push(minPriority)
861+
}
862+
863+
if (hasMaxPriority) {
864+
paramIndex++
865+
maxPriorityParam = `$${paramIndex}::int`
866+
values.push(maxPriority)
867+
}
868+
869+
return { values, ignoreSingletonsParam, ignoreGroupsParam, defaultGroupLimitParam, tiersParam, minPriorityParam, maxPriorityParam }
850870
}
851871

852872
function fetchNextJob (options: FetchJobOptions): SqlQuery {
853-
const { schema, table, name, policy, limit, includeMetadata, priority = true, orderByCreatedOn = true, ignoreStartAfter = false, groupConcurrency } = options
873+
const { schema, table, name, policy, limit, includeMetadata, priority = true, orderByCreatedOn = true, ignoreStartAfter = false, groupConcurrency, minPriority, maxPriority } = options
854874

855875
const singletonFetch = limit > 1 && (policy === QUEUE_POLICIES.singleton || policy === QUEUE_POLICIES.stately)
856876
const hasIgnoreSingletons = options.ignoreSingletons != null && options.ignoreSingletons.length > 0
857877
const hasIgnoreGroups = options.ignoreGroups != null && options.ignoreGroups.length > 0
858878
const hasGroupConcurrency = groupConcurrency != null
879+
const hasMinPriority = minPriority != null
880+
const hasMaxPriority = maxPriority != null
859881
const hasTiers = hasGroupConcurrency &&
860882
typeof groupConcurrency === 'object' &&
861883
groupConcurrency.tiers &&
@@ -868,7 +890,9 @@ function fetchNextJob (options: FetchJobOptions): SqlQuery {
868890
`state < '${JOB_STATES.active}'`,
869891
!ignoreStartAfter ? 'start_after < now()' : '',
870892
hasIgnoreSingletons ? `singleton_key <> ALL(${params.ignoreSingletonsParam})` : '',
871-
hasIgnoreGroups ? `(group_id IS NULL OR group_id <> ALL(${params.ignoreGroupsParam}))` : ''
893+
hasIgnoreGroups ? `(group_id IS NULL OR group_id <> ALL(${params.ignoreGroupsParam}))` : '',
894+
hasMinPriority ? `priority >= ${params.minPriorityParam}` : '',
895+
hasMaxPriority ? `priority <= ${params.maxPriorityParam}` : ''
872896
].filter(Boolean).join(' AND ')
873897

874898
const selectCols = [

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,20 @@ export interface JobFetchOptions {
315315
* @default false
316316
*/
317317
ignoreStartAfter?: boolean;
318+
/**
319+
* Only fetch jobs with a priority greater than or equal to this value.
320+
* Useful for reserving worker capacity exclusively for higher-priority jobs.
321+
* Must be an integer. If both `minPriority` and `maxPriority` are set,
322+
* `minPriority` must be less than or equal to `maxPriority`.
323+
*/
324+
minPriority?: number;
325+
/**
326+
* Only fetch jobs with a priority less than or equal to this value.
327+
* Useful for workers dedicated to lower-priority background work.
328+
* Must be an integer. If both `minPriority` and `maxPriority` are set,
329+
* `minPriority` must be less than or equal to `maxPriority`.
330+
*/
331+
maxPriority?: number;
318332
}
319333

320334
export interface WorkConcurrencyOptions {

test/priorityTest.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect } from 'vitest'
22
import * as helper from './testHelper.ts'
33
import { ctx } from './hooks.ts'
4+
import { assertTruthy } from './testHelper.ts'
45

56
describe('priority', function () {
67
it('higher priority job', async function () {
@@ -46,4 +47,104 @@ describe('priority', function () {
4647
expect(job2.id).toBe(medium)
4748
expect(job3.id).toBe(high)
4849
})
50+
51+
it('minPriority skips jobs below threshold', async function () {
52+
ctx.boss = await helper.start(ctx.bossConfig)
53+
54+
await ctx.boss.send(ctx.schema, null, { priority: -10 })
55+
const normal = await ctx.boss.send(ctx.schema, null, { priority: 0 })
56+
57+
const [job] = await ctx.boss.fetch(ctx.schema, { minPriority: 0 })
58+
59+
expect(job.id).toBe(normal)
60+
})
61+
62+
it('minPriority returns nothing when all jobs are below threshold', async function () {
63+
ctx.boss = await helper.start(ctx.bossConfig)
64+
65+
await ctx.boss.send(ctx.schema, null, { priority: -10 })
66+
await ctx.boss.send(ctx.schema, null, { priority: -5 })
67+
68+
const jobs = await ctx.boss.fetch(ctx.schema, { minPriority: 0 })
69+
70+
expect(jobs.length).toBe(0)
71+
})
72+
73+
it('maxPriority skips jobs above threshold', async function () {
74+
ctx.boss = await helper.start(ctx.bossConfig)
75+
76+
const low = await ctx.boss.send(ctx.schema, null, { priority: -10 })
77+
await ctx.boss.send(ctx.schema, null, { priority: 5 })
78+
79+
const [job] = await ctx.boss.fetch(ctx.schema, { maxPriority: 0 })
80+
81+
expect(job.id).toBe(low)
82+
})
83+
84+
it('maxPriority returns nothing when all jobs are above threshold', async function () {
85+
ctx.boss = await helper.start(ctx.bossConfig)
86+
87+
await ctx.boss.send(ctx.schema, null, { priority: 5 })
88+
await ctx.boss.send(ctx.schema, null, { priority: 10 })
89+
90+
const jobs = await ctx.boss.fetch(ctx.schema, { maxPriority: 0 })
91+
92+
expect(jobs.length).toBe(0)
93+
})
94+
95+
it('minPriority and maxPriority together fetch only jobs in range', async function () {
96+
ctx.boss = await helper.start(ctx.bossConfig)
97+
98+
await ctx.boss.send(ctx.schema, null, { priority: -10 })
99+
const inRange = await ctx.boss.send(ctx.schema, null, { priority: 5 })
100+
await ctx.boss.send(ctx.schema, null, { priority: 20 })
101+
102+
const [job] = await ctx.boss.fetch(ctx.schema, { minPriority: 1, maxPriority: 10 })
103+
104+
expect(job.id).toBe(inRange)
105+
})
106+
107+
it('worker with minPriority skips jobs below threshold', async function () {
108+
ctx.boss = await helper.start({ ...ctx.bossConfig, __test__enableSpies: true })
109+
110+
const spy = ctx.boss.getSpy(ctx.schema)
111+
112+
const skipped = await ctx.boss.send(ctx.schema, null, { priority: -10 })
113+
const normal = await ctx.boss.send(ctx.schema, null, { priority: 0 })
114+
115+
assertTruthy(skipped)
116+
assertTruthy(normal)
117+
118+
await ctx.boss.work(ctx.schema, { minPriority: 0 }, async () => {})
119+
120+
await spy.waitForJobWithId(normal, 'completed')
121+
await ctx.boss.offWork(ctx.schema)
122+
123+
const [remainingJob] = await ctx.boss.findJobs(ctx.schema, { id: skipped })
124+
125+
assertTruthy(remainingJob)
126+
expect(remainingJob.state).toBe('created')
127+
})
128+
129+
it('worker with maxPriority skips jobs above threshold', async function () {
130+
ctx.boss = await helper.start({ ...ctx.bossConfig, __test__enableSpies: true })
131+
132+
const spy = ctx.boss.getSpy(ctx.schema)
133+
134+
const low = await ctx.boss.send(ctx.schema, null, { priority: -10 })
135+
const skipped = await ctx.boss.send(ctx.schema, null, { priority: 5 })
136+
137+
assertTruthy(low)
138+
assertTruthy(skipped)
139+
140+
await ctx.boss.work(ctx.schema, { maxPriority: 0 }, async () => {})
141+
142+
await spy.waitForJobWithId(low, 'completed')
143+
await ctx.boss.offWork(ctx.schema)
144+
145+
const [remainingJob] = await ctx.boss.findJobs(ctx.schema, { id: skipped })
146+
147+
assertTruthy(remainingJob)
148+
expect(remainingJob.state).toBe('created')
149+
})
49150
})

0 commit comments

Comments
 (0)