@pikku/queue-pg-boss 0.12.7 → 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,47 @@
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
+
1
45
  ## 0.12.7
2
46
 
3
47
  ### Patch Changes
@@ -10,6 +10,12 @@ 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;
@@ -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.7",
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.56"
18
+ "@pikku/core": "^0.12.69"
19
19
  },
20
20
  "dependencies": {
21
21
  "pg-boss": "^12.14.0"
@@ -57,3 +57,22 @@ describe('mapPikkuJobToPgBoss — retry mapping', () => {
57
57
  assert.equal(opts.retryBackoff, undefined)
58
58
  })
59
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,6 +19,13 @@ 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
@@ -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',