@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 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
- if (typeof options.backoff === 'string') {
19
- // If backoff is a string, assume it's 'exponential' or 'fixed'
20
- pgBossOptions.retryBackoff = options.backoff === 'exponential';
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 (typeof options.backoff === 'object') {
23
- pgBossOptions.retryBackoff = options.backoff.type === 'exponential';
24
- if (options.backoff.delay !== undefined) {
25
- // pg-boss uses seconds, we use milliseconds
26
- pgBossOptions.retryDelay = Math.floor(options.backoff.delay / 1000);
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
- batchSize: 10,
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: 'batchSize',
32
- description: 'Number of jobs to fetch and process concurrently in parallel',
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.6",
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.44"
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
- cd "$(dirname "$0")"
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
- if [ ${#files[@]} -eq 0 ]; then echo "No test files found"; exit 0; fi
5
- node --import tsx --test "$@" "${files[@]}"
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
- assert.equal(
21
- mapPikkuJobToPgBoss({ attempts: 6, backoff: 'exponential' })
22
- .retryBackoff,
23
- true
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
- if (typeof options.backoff === 'string') {
29
- // If backoff is a string, assume it's 'exponential' or 'fixed'
30
- pgBossOptions.retryBackoff = options.backoff === 'exponential'
31
- } else if (typeof options.backoff === 'object') {
32
- pgBossOptions.retryBackoff = options.backoff.type === 'exponential'
33
- if (options.backoff.delay !== undefined) {
34
- // pg-boss uses seconds, we use milliseconds
35
- pgBossOptions.retryDelay = Math.floor(options.backoff.delay / 1000)
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 { PgBossQueueWorkers } from './pg-boss-queue-worker.js'
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
- batchSize: 10,
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: 'batchSize',
84
+ queueProperty: 'localConcurrency',
85
+ description: 'Number of jobs to process concurrently',
86
+ },
87
+ groupConcurrency: {
88
+ queueProperty: 'localGroupConcurrency',
55
89
  description:
56
- 'Number of jobs to fetch and process concurrently in parallel',
90
+ 'Maximum concurrent jobs per group, keeping one shared queue fair across groups',
57
91
  },
58
92
  pollInterval: {
59
93
  queueProperty: 'pollingIntervalSeconds',