@pikku/queue-pg-boss 0.12.6 → 0.12.8
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/CHANGELOG.md +55 -0
- package/dist/pg-boss-queue-service.js +18 -9
- package/dist/pg-boss-queue-worker.js +36 -6
- package/package.json +2 -2
- package/run-tests.sh +54 -3
- package/src/pg-boss-queue-service.test.ts +33 -6
- package/src/pg-boss-queue-service.ts +21 -9
- package/src/pg-boss-queue-worker.test.ts +79 -1
- package/src/pg-boss-queue-worker.ts +41 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
## 0.12.8
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 6847338: Stop one slow job from holding an entire queue (head-of-line blocking).
|
|
6
|
+
|
|
7
|
+
The worker mapped pikku's parallelism to pg-boss `batchSize`, whose handler
|
|
8
|
+
receives the whole fetched batch and only fetches again once that handler
|
|
9
|
+
resolves. Because the handler `Promise.all`s the batch, a single long-running
|
|
10
|
+
job blocked every sibling in its batch _and_ stalled the next fetch — so on a
|
|
11
|
+
shared queue one slow job could freeze the worker and starve everything behind
|
|
12
|
+
it (observed with workflow-orchestrator jobs, where a long step pinned all 10
|
|
13
|
+
slots for tens of minutes).
|
|
14
|
+
|
|
15
|
+
Parallelism now maps to pg-boss `localConcurrency` with `batchSize: 1`: N
|
|
16
|
+
independent workers each fetch and process a single job and refill the instant
|
|
17
|
+
they finish, so a slow job never holds up the others. Same effective
|
|
18
|
+
concurrency, no head-of-line blocking.
|
|
19
|
+
|
|
20
|
+
- e3d4454: Add job groups, so one shared queue can stay fair without splitting into one
|
|
21
|
+
queue per producer.
|
|
22
|
+
|
|
23
|
+
A job may now carry `group: { id, tier }`, and a worker may cap how many jobs
|
|
24
|
+
of any one group run at once via `groupConcurrency`. On pg-boss this maps to
|
|
25
|
+
`localGroupConcurrency`, which excludes at-capacity groups from the fetch query
|
|
26
|
+
itself, so a capped group costs nothing rather than being fetched and restored.
|
|
27
|
+
BullMQ declares it unsupported (groups are a BullMQ Pro feature) — being
|
|
28
|
+
push-based, it can simply use a queue per group at no polling cost.
|
|
29
|
+
|
|
30
|
+
Workflow services accept a `queueStrategy`. The default `'per-workflow'` is
|
|
31
|
+
unchanged: every workflow gets its own `wf-orchestrator-*` / `wf-step-*` queue,
|
|
32
|
+
which is also what lets serverless providers deploy one unit per workflow. The
|
|
33
|
+
new `'shared-groups'` routes every workflow through the shared
|
|
34
|
+
orchestrator/step-worker queues and isolates them by group instead, so a
|
|
35
|
+
monolith runs one set of pollers rather than one per workflow — on a
|
|
36
|
+
pull-based backend with dozens of workflows that is the difference between
|
|
37
|
+
hundreds of poll loops and twenty. It is for single-process runtimes only; a
|
|
38
|
+
per-unit serverless deploy still needs the per-workflow queues to route to its
|
|
39
|
+
units.
|
|
40
|
+
|
|
41
|
+
- Updated dependencies [24252b8]
|
|
42
|
+
- Updated dependencies [e3d4454]
|
|
43
|
+
- @pikku/core@0.12.69
|
|
44
|
+
|
|
45
|
+
## 0.12.7
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 6c30861: fix workflow step retry backoff firing immediately
|
|
50
|
+
- `@pikku/queue-pg-boss`: `backoff: 'exponential'` mapped to `retryBackoff: true` without a base `retryDelay`; pg-boss computes exponential backoff as `retry_delay * 2^n` with a queue default of 0, so every retry fired immediately. Exponential backoff now gets a 1s base delay, and sub-second fixed delays round up to 1s instead of flooring to 0 (= immediate).
|
|
51
|
+
- `@pikku/core`: a duration-string `retryDelay` (e.g. `'15s'`) on a workflow step was silently dropped (only numbers were honored) and fell back to exponential. It now resolves to a fixed backoff via `getDurationInMilliseconds`.
|
|
52
|
+
|
|
53
|
+
- Updated dependencies [6c30861]
|
|
54
|
+
- @pikku/core@0.12.56
|
|
55
|
+
|
|
1
56
|
## 0.12.6
|
|
2
57
|
|
|
3
58
|
### Patch Changes
|
|
@@ -10,21 +10,30 @@ export const mapPikkuJobToPgBoss = (options) => {
|
|
|
10
10
|
if (options?.jobId !== undefined) {
|
|
11
11
|
pgBossOptions.singletonKey = options.jobId;
|
|
12
12
|
}
|
|
13
|
+
if (options?.group !== undefined) {
|
|
14
|
+
pgBossOptions.group = {
|
|
15
|
+
id: options.group.id,
|
|
16
|
+
...(options.group.tier !== undefined ? { tier: options.group.tier } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
13
19
|
// Map retry options
|
|
14
20
|
if (options?.attempts !== undefined) {
|
|
15
21
|
pgBossOptions.retryLimit = options.attempts - 1;
|
|
16
22
|
}
|
|
17
23
|
if (options?.backoff !== undefined) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
const type = typeof options.backoff === 'string' ? options.backoff : options.backoff.type;
|
|
25
|
+
const delay = typeof options.backoff === 'object' ? options.backoff.delay : undefined;
|
|
26
|
+
pgBossOptions.retryBackoff = type === 'exponential';
|
|
27
|
+
if (delay !== undefined) {
|
|
28
|
+
// pg-boss uses seconds, we use milliseconds — never round a sub-second
|
|
29
|
+
// delay down to 0, which pg-boss treats as retry-immediately
|
|
30
|
+
pgBossOptions.retryDelay = Math.max(1, Math.round(delay / 1000));
|
|
21
31
|
}
|
|
22
|
-
else if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
32
|
+
else if (type === 'exponential') {
|
|
33
|
+
// pg-boss computes exponential backoff as retry_delay * 2^n, and the
|
|
34
|
+
// queue-level retry_delay defaults to 0 — without an explicit base every
|
|
35
|
+
// "exponential" retry fires immediately. 1s base → ~1s, 2s, 4s, 8s…
|
|
36
|
+
pgBossOptions.retryDelay = 1;
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
39
|
if (options?.removeOnComplete !== undefined) {
|
|
@@ -2,15 +2,41 @@ import { registerQueueWorkers, runQueueJob, QueueJobFailedError, QueueJobDiscard
|
|
|
2
2
|
import { pikkuState } from '@pikku/core/internal';
|
|
3
3
|
import { mapPgBossJobToQueueJob } from './utils.js';
|
|
4
4
|
export const mapPikkuWorkerToPgBoss = (workerConfig) => {
|
|
5
|
+
// Map the requested parallelism to pg-boss `localConcurrency` (independent
|
|
6
|
+
// per-job workers), NOT `batchSize`. A batch worker hands the whole fetched
|
|
7
|
+
// batch to one handler and only fetches again once that handler resolves — so
|
|
8
|
+
// a single slow job blocks every sibling in its batch AND stalls the next
|
|
9
|
+
// fetch. On a shared queue that is head-of-line blocking: one long job can
|
|
10
|
+
// freeze the entire worker and starve everything queued behind it.
|
|
11
|
+
// `localConcurrency` spawns N pollers that each fetch+process a single job and
|
|
12
|
+
// refill the instant they finish, so a slow job never holds up the others.
|
|
13
|
+
const localConcurrency = workerConfig?.batchSize ?? 10;
|
|
5
14
|
const workerOptions = {
|
|
6
|
-
|
|
15
|
+
localConcurrency,
|
|
16
|
+
batchSize: 1,
|
|
7
17
|
};
|
|
8
|
-
if (workerConfig?.batchSize !== undefined) {
|
|
9
|
-
workerOptions.batchSize = workerConfig.batchSize;
|
|
10
|
-
}
|
|
11
18
|
if (workerConfig?.pollInterval !== undefined) {
|
|
12
19
|
workerOptions.pollingIntervalSeconds = Math.round(workerConfig.pollInterval / 1000);
|
|
13
20
|
}
|
|
21
|
+
// Per-group fairness. `localGroupConcurrency` is in-memory and free — groups
|
|
22
|
+
// already at capacity are excluded from the fetch itself, so an at-capacity
|
|
23
|
+
// group costs nothing rather than being fetched and restored. pg-boss asserts
|
|
24
|
+
// every limit is <= localConcurrency, so clamp instead of letting a config
|
|
25
|
+
// that reads as "let this tier use more" throw at worker start.
|
|
26
|
+
if (workerConfig?.groupConcurrency !== undefined) {
|
|
27
|
+
const clamp = (limit) => Math.min(limit, localConcurrency);
|
|
28
|
+
workerOptions.localGroupConcurrency =
|
|
29
|
+
typeof workerConfig.groupConcurrency === 'number'
|
|
30
|
+
? clamp(workerConfig.groupConcurrency)
|
|
31
|
+
: {
|
|
32
|
+
default: clamp(workerConfig.groupConcurrency.default),
|
|
33
|
+
...(workerConfig.groupConcurrency.tiers
|
|
34
|
+
? {
|
|
35
|
+
tiers: Object.fromEntries(Object.entries(workerConfig.groupConcurrency.tiers).map(([tier, limit]) => [tier, clamp(limit)])),
|
|
36
|
+
}
|
|
37
|
+
: {}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
14
40
|
return workerOptions;
|
|
15
41
|
};
|
|
16
42
|
/**
|
|
@@ -28,8 +54,12 @@ export class PgBossQueueWorkers {
|
|
|
28
54
|
// Configurations that are directly supported
|
|
29
55
|
supported: {
|
|
30
56
|
batchSize: {
|
|
31
|
-
queueProperty: '
|
|
32
|
-
description: 'Number of jobs to
|
|
57
|
+
queueProperty: 'localConcurrency',
|
|
58
|
+
description: 'Number of jobs to process concurrently',
|
|
59
|
+
},
|
|
60
|
+
groupConcurrency: {
|
|
61
|
+
queueProperty: 'localGroupConcurrency',
|
|
62
|
+
description: 'Maximum concurrent jobs per group, keeping one shared queue fair across groups',
|
|
33
63
|
},
|
|
34
64
|
pollInterval: {
|
|
35
65
|
queueProperty: 'pollingIntervalSeconds',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/queue-pg-boss",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.8",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"prepublishOnly": "yarn build"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"@pikku/core": "^0.12.
|
|
18
|
+
"@pikku/core": "^0.12.69"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"pg-boss": "^12.14.0"
|
package/run-tests.sh
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
4
|
+
cd "$script_dir"
|
|
5
|
+
|
|
6
|
+
# Enable nullglob to handle cases where no files match the pattern
|
|
7
|
+
shopt -s nullglob
|
|
8
|
+
|
|
9
|
+
# Initialize variables for options
|
|
10
|
+
watch_mode=false
|
|
11
|
+
coverage_mode=false
|
|
12
|
+
|
|
13
|
+
# Parse command-line options
|
|
14
|
+
while [[ $# -gt 0 ]]; do
|
|
15
|
+
case $1 in
|
|
16
|
+
--watch)
|
|
17
|
+
watch_mode=true
|
|
18
|
+
shift
|
|
19
|
+
;;
|
|
20
|
+
--coverage)
|
|
21
|
+
coverage_mode=true
|
|
22
|
+
shift
|
|
23
|
+
;;
|
|
24
|
+
*)
|
|
25
|
+
echo "Unknown option: $1"
|
|
26
|
+
exit 1
|
|
27
|
+
;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
# Define the pattern to match your test files
|
|
32
|
+
pattern="src/*.test.ts"
|
|
33
|
+
|
|
34
|
+
# Expand the pattern into an array of files
|
|
3
35
|
files=($(find src -type f -name "*.test.ts"))
|
|
4
|
-
|
|
5
|
-
|
|
36
|
+
|
|
37
|
+
# Check if any files matched the pattern
|
|
38
|
+
if [ ${#files[@]} -eq 0 ]; then
|
|
39
|
+
echo "No test files found matching pattern: $pattern"
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Construct the node command
|
|
44
|
+
node_cmd=(node --import tsx --test)
|
|
45
|
+
|
|
46
|
+
# Append options based on flags
|
|
47
|
+
if [ "$watch_mode" = true ]; then
|
|
48
|
+
node_cmd+=(--watch)
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [ "$coverage_mode" = true ]; then
|
|
52
|
+
node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Execute the node command with the expanded list of files
|
|
56
|
+
"${node_cmd[@]}" "${files[@]}"
|
|
@@ -16,12 +16,20 @@ describe('mapPikkuJobToPgBoss — retry mapping', () => {
|
|
|
16
16
|
assert.equal(mapPikkuJobToPgBoss({ attempts: 1 }).retryLimit, 0)
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
test("backoff 'exponential' → retryBackoff true", () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
)
|
|
19
|
+
test("backoff 'exponential' → retryBackoff true + 1s base delay", () => {
|
|
20
|
+
const opts = mapPikkuJobToPgBoss({ attempts: 6, backoff: 'exponential' })
|
|
21
|
+
assert.equal(opts.retryBackoff, true)
|
|
22
|
+
// pg-boss multiplies retry_delay by 2^n and the queue default is 0 —
|
|
23
|
+
// without a base, "exponential" retries all fire immediately.
|
|
24
|
+
assert.equal(opts.retryDelay, 1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('sub-second fixed delay rounds up to 1s, not down to immediate', () => {
|
|
28
|
+
const opts = mapPikkuJobToPgBoss({
|
|
29
|
+
attempts: 4,
|
|
30
|
+
backoff: { type: 'fixed', delay: 300 },
|
|
31
|
+
})
|
|
32
|
+
assert.equal(opts.retryDelay, 1)
|
|
25
33
|
})
|
|
26
34
|
|
|
27
35
|
test('fixed backoff → retryBackoff false + retryDelay in seconds', () => {
|
|
@@ -49,3 +57,22 @@ describe('mapPikkuJobToPgBoss — retry mapping', () => {
|
|
|
49
57
|
assert.equal(opts.retryBackoff, undefined)
|
|
50
58
|
})
|
|
51
59
|
})
|
|
60
|
+
|
|
61
|
+
describe('mapPikkuJobToPgBoss — group mapping', () => {
|
|
62
|
+
test('group id is forwarded so the worker can enforce a per-group limit', () => {
|
|
63
|
+
assert.deepEqual(mapPikkuJobToPgBoss({ group: { id: 'deployWorkflow' } })
|
|
64
|
+
.group, { id: 'deployWorkflow' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('tier is only set when provided', () => {
|
|
68
|
+
const opts = mapPikkuJobToPgBoss({
|
|
69
|
+
group: { id: 'deployWorkflow', tier: 'slow' },
|
|
70
|
+
})
|
|
71
|
+
assert.deepEqual(opts.group, { id: 'deployWorkflow', tier: 'slow' })
|
|
72
|
+
assert.equal('tier' in mapPikkuJobToPgBoss({ group: { id: 'a' } }).group!, false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('no group → no group field', () => {
|
|
76
|
+
assert.equal(mapPikkuJobToPgBoss({ attempts: 2 }).group, undefined)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -19,21 +19,33 @@ export const mapPikkuJobToPgBoss = (
|
|
|
19
19
|
pgBossOptions.singletonKey = options.jobId
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
if (options?.group !== undefined) {
|
|
23
|
+
pgBossOptions.group = {
|
|
24
|
+
id: options.group.id,
|
|
25
|
+
...(options.group.tier !== undefined ? { tier: options.group.tier } : {}),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
// Map retry options
|
|
23
30
|
if (options?.attempts !== undefined) {
|
|
24
31
|
pgBossOptions.retryLimit = options.attempts - 1
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
if (options?.backoff !== undefined) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
const type =
|
|
36
|
+
typeof options.backoff === 'string' ? options.backoff : options.backoff.type
|
|
37
|
+
const delay =
|
|
38
|
+
typeof options.backoff === 'object' ? options.backoff.delay : undefined
|
|
39
|
+
pgBossOptions.retryBackoff = type === 'exponential'
|
|
40
|
+
if (delay !== undefined) {
|
|
41
|
+
// pg-boss uses seconds, we use milliseconds — never round a sub-second
|
|
42
|
+
// delay down to 0, which pg-boss treats as retry-immediately
|
|
43
|
+
pgBossOptions.retryDelay = Math.max(1, Math.round(delay / 1000))
|
|
44
|
+
} else if (type === 'exponential') {
|
|
45
|
+
// pg-boss computes exponential backoff as retry_delay * 2^n, and the
|
|
46
|
+
// queue-level retry_delay defaults to 0 — without an explicit base every
|
|
47
|
+
// "exponential" retry fires immediately. 1s base → ~1s, 2s, 4s, 8s…
|
|
48
|
+
pgBossOptions.retryDelay = 1
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, test } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
PgBossQueueWorkers,
|
|
6
|
+
mapPikkuWorkerToPgBoss,
|
|
7
|
+
} from './pg-boss-queue-worker.js'
|
|
5
8
|
|
|
6
9
|
describe('PgBossQueueWorkers', () => {
|
|
7
10
|
test('requires a logger (explicit or from singleton services)', async () => {
|
|
@@ -13,3 +16,78 @@ describe('PgBossQueueWorkers', () => {
|
|
|
13
16
|
})
|
|
14
17
|
})
|
|
15
18
|
})
|
|
19
|
+
|
|
20
|
+
describe('mapPikkuWorkerToPgBoss', () => {
|
|
21
|
+
test('parallelism maps to independent workers (localConcurrency), never batch fetching', () => {
|
|
22
|
+
// batchSize: 1 keeps each worker single-job so a slow job can never hold its
|
|
23
|
+
// batch siblings — head-of-line blocking is impossible.
|
|
24
|
+
assert.deepEqual(mapPikkuWorkerToPgBoss(), {
|
|
25
|
+
localConcurrency: 10,
|
|
26
|
+
batchSize: 1,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(mapPikkuWorkerToPgBoss({ batchSize: 25 }), {
|
|
30
|
+
localConcurrency: 25,
|
|
31
|
+
batchSize: 1,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('pollInterval is converted from ms to seconds', () => {
|
|
36
|
+
assert.deepEqual(mapPikkuWorkerToPgBoss({ pollInterval: 3000 }), {
|
|
37
|
+
localConcurrency: 10,
|
|
38
|
+
batchSize: 1,
|
|
39
|
+
pollingIntervalSeconds: 3,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('groupConcurrency caps how much of the worker one group can take', () => {
|
|
44
|
+
assert.deepEqual(mapPikkuWorkerToPgBoss({ groupConcurrency: 2 }), {
|
|
45
|
+
localConcurrency: 10,
|
|
46
|
+
batchSize: 1,
|
|
47
|
+
localGroupConcurrency: 2,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('tiered groupConcurrency passes tiers through', () => {
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
mapPikkuWorkerToPgBoss({
|
|
54
|
+
batchSize: 20,
|
|
55
|
+
groupConcurrency: { default: 2, tiers: { slowWorkflow: 1 } },
|
|
56
|
+
}),
|
|
57
|
+
{
|
|
58
|
+
localConcurrency: 20,
|
|
59
|
+
batchSize: 1,
|
|
60
|
+
localGroupConcurrency: { default: 2, tiers: { slowWorkflow: 1 } },
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('group limits are clamped to the worker concurrency', () => {
|
|
66
|
+
// pg-boss asserts every group limit is <= localConcurrency and throws at
|
|
67
|
+
// worker start otherwise — clamp so an over-eager config degrades to "this
|
|
68
|
+
// group may use the whole worker" instead of failing to start.
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
mapPikkuWorkerToPgBoss({
|
|
71
|
+
batchSize: 4,
|
|
72
|
+
groupConcurrency: { default: 10, tiers: { greedy: 99 } },
|
|
73
|
+
}),
|
|
74
|
+
{
|
|
75
|
+
localConcurrency: 4,
|
|
76
|
+
batchSize: 1,
|
|
77
|
+
localGroupConcurrency: { default: 4, tiers: { greedy: 4 } },
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
mapPikkuWorkerToPgBoss({ batchSize: 3, groupConcurrency: 8 }),
|
|
83
|
+
{ localConcurrency: 3, batchSize: 1, localGroupConcurrency: 3 }
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('no group config means no group limits', () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
'localGroupConcurrency' in mapPikkuWorkerToPgBoss({ batchSize: 5 }),
|
|
90
|
+
false
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -18,12 +18,18 @@ import { mapPgBossJobToQueueJob } from './utils.js'
|
|
|
18
18
|
export const mapPikkuWorkerToPgBoss = (
|
|
19
19
|
workerConfig?: PikkuWorkerConfig
|
|
20
20
|
): WorkOptions => {
|
|
21
|
+
// Map the requested parallelism to pg-boss `localConcurrency` (independent
|
|
22
|
+
// per-job workers), NOT `batchSize`. A batch worker hands the whole fetched
|
|
23
|
+
// batch to one handler and only fetches again once that handler resolves — so
|
|
24
|
+
// a single slow job blocks every sibling in its batch AND stalls the next
|
|
25
|
+
// fetch. On a shared queue that is head-of-line blocking: one long job can
|
|
26
|
+
// freeze the entire worker and starve everything queued behind it.
|
|
27
|
+
// `localConcurrency` spawns N pollers that each fetch+process a single job and
|
|
28
|
+
// refill the instant they finish, so a slow job never holds up the others.
|
|
29
|
+
const localConcurrency = workerConfig?.batchSize ?? 10
|
|
21
30
|
const workerOptions: WorkOptions = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (workerConfig?.batchSize !== undefined) {
|
|
26
|
-
workerOptions.batchSize = workerConfig.batchSize
|
|
31
|
+
localConcurrency,
|
|
32
|
+
batchSize: 1,
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
if (workerConfig?.pollInterval !== undefined) {
|
|
@@ -32,6 +38,30 @@ export const mapPikkuWorkerToPgBoss = (
|
|
|
32
38
|
)
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
// Per-group fairness. `localGroupConcurrency` is in-memory and free — groups
|
|
42
|
+
// already at capacity are excluded from the fetch itself, so an at-capacity
|
|
43
|
+
// group costs nothing rather than being fetched and restored. pg-boss asserts
|
|
44
|
+
// every limit is <= localConcurrency, so clamp instead of letting a config
|
|
45
|
+
// that reads as "let this tier use more" throw at worker start.
|
|
46
|
+
if (workerConfig?.groupConcurrency !== undefined) {
|
|
47
|
+
const clamp = (limit: number) => Math.min(limit, localConcurrency)
|
|
48
|
+
workerOptions.localGroupConcurrency =
|
|
49
|
+
typeof workerConfig.groupConcurrency === 'number'
|
|
50
|
+
? clamp(workerConfig.groupConcurrency)
|
|
51
|
+
: {
|
|
52
|
+
default: clamp(workerConfig.groupConcurrency.default),
|
|
53
|
+
...(workerConfig.groupConcurrency.tiers
|
|
54
|
+
? {
|
|
55
|
+
tiers: Object.fromEntries(
|
|
56
|
+
Object.entries(workerConfig.groupConcurrency.tiers).map(
|
|
57
|
+
([tier, limit]) => [tier, clamp(limit)]
|
|
58
|
+
)
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
: {}),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
return workerOptions
|
|
36
66
|
}
|
|
37
67
|
|
|
@@ -51,9 +81,13 @@ export class PgBossQueueWorkers implements QueueWorkers {
|
|
|
51
81
|
// Configurations that are directly supported
|
|
52
82
|
supported: {
|
|
53
83
|
batchSize: {
|
|
54
|
-
queueProperty: '
|
|
84
|
+
queueProperty: 'localConcurrency',
|
|
85
|
+
description: 'Number of jobs to process concurrently',
|
|
86
|
+
},
|
|
87
|
+
groupConcurrency: {
|
|
88
|
+
queueProperty: 'localGroupConcurrency',
|
|
55
89
|
description:
|
|
56
|
-
'
|
|
90
|
+
'Maximum concurrent jobs per group, keeping one shared queue fair across groups',
|
|
57
91
|
},
|
|
58
92
|
pollInterval: {
|
|
59
93
|
queueProperty: 'pollingIntervalSeconds',
|