@pikku/core 0.12.67 → 0.12.69

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,66 @@
1
+ ## 0.12.69
2
+
3
+ ### Patch Changes
4
+
5
+ - 24252b8: Emit queue meta for workflow-only projects, so per-workflow orchestrator queues actually work.
6
+
7
+ Workflows synthesise their own `wf-orchestrator-*` / `wf-step-*` queue meta during
8
+ post-processing, and those entries have no declaring source file. The queue codegen
9
+ bailed early on `queueWorkers.files.size === 0`, so a project that uses workflows but
10
+ hand-declares no `wireQueueWorker` wrote no queue meta at all — and the generated
11
+ bootstrap therefore never imported it.
12
+
13
+ With `queue.meta` empty at runtime, `getOrchestratorQueueName()` never found a
14
+ per-workflow queue and every workflow silently fell back to the single shared
15
+ `pikku-workflow-orchestrator` queue. Nothing failed, but the isolation was gone: one
16
+ long-running workflow step head-of-line-blocked every other workflow queued behind it.
17
+
18
+ The codegen now gates on the meta alone. `@pikku/core` additionally warns at wiring
19
+ time when workflows are registered but no per-workflow orchestrator queue is present,
20
+ so this degradation can't recur silently.
21
+
22
+ - e3d4454: Add job groups, so one shared queue can stay fair without splitting into one
23
+ queue per producer.
24
+
25
+ A job may now carry `group: { id, tier }`, and a worker may cap how many jobs
26
+ of any one group run at once via `groupConcurrency`. On pg-boss this maps to
27
+ `localGroupConcurrency`, which excludes at-capacity groups from the fetch query
28
+ itself, so a capped group costs nothing rather than being fetched and restored.
29
+ BullMQ declares it unsupported (groups are a BullMQ Pro feature) — being
30
+ push-based, it can simply use a queue per group at no polling cost.
31
+
32
+ Workflow services accept a `queueStrategy`. The default `'per-workflow'` is
33
+ unchanged: every workflow gets its own `wf-orchestrator-*` / `wf-step-*` queue,
34
+ which is also what lets serverless providers deploy one unit per workflow. The
35
+ new `'shared-groups'` routes every workflow through the shared
36
+ orchestrator/step-worker queues and isolates them by group instead, so a
37
+ monolith runs one set of pollers rather than one per workflow — on a
38
+ pull-based backend with dozens of workflows that is the difference between
39
+ hundreds of poll loops and twenty. It is for single-process runtimes only; a
40
+ per-unit serverless deploy still needs the per-workflow queues to route to its
41
+ units.
42
+
43
+ ## 0.12.68
44
+
45
+ ### Patch Changes
46
+
47
+ - f11675f: Forward the parent run's `context` into delegated sub-agent invocations.
48
+
49
+ A supervisor agent's injected `context` (the "Current context" block holding the
50
+ authoritative identifiers — organizationId, project/stage ids) was appended only
51
+ to the supervisor's own instructions. When it delegated, the sub-agent tool's
52
+ input schema carries just `{ message, session }`, and `buildToolDefs` invoked the
53
+ sub-agent with `{ message, threadId, resourceId }` — dropping the context. The
54
+ sub-agent therefore never saw the real ids and depended on the model re-typing
55
+ them into the free-text `message`, which weaker models routinely botch, producing
56
+ schema-validation and permission rejections that the agent then retries — burning
57
+ steps and ballooning the transcript.
58
+
59
+ `buildToolDefs` now takes the parent `context` and forwards it (via the new
60
+ `buildSubAgentRunInput` helper) into both the streaming and non-streaming
61
+ sub-agent invocations, so a specialist inherits the same identifier block in its
62
+ instructions.
63
+
1
64
  ## 0.12.67
2
65
 
3
66
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
2
2
  import type { SerializedError } from '../types/core.types.js';
3
- import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
3
+ import type { WorkflowPlannedStep, WorkflowQueueOptions, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
4
4
  /**
5
5
  * In-memory implementation of WorkflowService for inline-only execution
6
6
  *
@@ -17,7 +17,7 @@ import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunW
17
17
  * ```
18
18
  */
19
19
  export declare class InMemoryWorkflowService extends PikkuWorkflowService implements WorkflowRunService {
20
- constructor();
20
+ constructor(options?: WorkflowQueueOptions);
21
21
  private sleepTimers;
22
22
  private runs;
23
23
  private steps;
@@ -17,8 +17,8 @@ import { isExpectedError } from '../errors/error-handler.js';
17
17
  * ```
18
18
  */
19
19
  export class InMemoryWorkflowService extends PikkuWorkflowService {
20
- constructor() {
21
- super({ wireQueues: false });
20
+ constructor(options = {}) {
21
+ super({ ...options, wireQueues: false });
22
22
  }
23
23
  sleepTimers = new Set();
24
24
  runs = new Map();
@@ -168,7 +168,24 @@ export type ScopedChannel = AIStreamChannel & {
168
168
  }>;
169
169
  };
170
170
  export declare function createScopedChannel(parent: AIStreamChannel, agentName: string, session: string): ScopedChannel;
171
- export declare function buildToolDefs(params: RunAIAgentParams, agentSessionMap: Map<string, string>, resourceId: string, agentName: string, packageName: string | null, streamContext?: StreamContext, aiMiddlewares?: PikkuAIMiddlewareHooks[], agentMode?: 'delegate' | 'supervise'): Promise<{
171
+ /**
172
+ * Build the run input for a delegated sub-agent.
173
+ *
174
+ * `context` is the PARENT run's identifier block (the "Current context" text
175
+ * with organizationId, project/stage ids). A sub-agent's tool-call schema only
176
+ * carries { message, session }, so unless the sub-agent inherits the parent's
177
+ * context it never sees the authoritative ids — it depends on the model
178
+ * re-typing them into `message`, which weak models botch, causing
179
+ * schema/permission rejections and retry loops. Forwarding it here is the
180
+ * regression this seam guards.
181
+ */
182
+ export declare function buildSubAgentRunInput(message: string, threadId: string, resourceId: string, parentContext?: string): {
183
+ message: string;
184
+ threadId: string;
185
+ resourceId: string;
186
+ context?: string;
187
+ };
188
+ export declare function buildToolDefs(params: RunAIAgentParams, agentSessionMap: Map<string, string>, resourceId: string, agentName: string, packageName: string | null, streamContext?: StreamContext, aiMiddlewares?: PikkuAIMiddlewareHooks[], agentMode?: 'delegate' | 'supervise', parentContext?: string): Promise<{
172
189
  tools: AIAgentToolDef[];
173
190
  missingRpcs: string[];
174
191
  }>;
@@ -325,7 +325,29 @@ export function createScopedChannel(parent, agentName, session) {
325
325
  clearState: () => parent.clearState(),
326
326
  };
327
327
  }
328
- export async function buildToolDefs(params, agentSessionMap, resourceId, agentName, packageName, streamContext, aiMiddlewares, agentMode) {
328
+ /**
329
+ * Build the run input for a delegated sub-agent.
330
+ *
331
+ * `context` is the PARENT run's identifier block (the "Current context" text
332
+ * with organizationId, project/stage ids). A sub-agent's tool-call schema only
333
+ * carries { message, session }, so unless the sub-agent inherits the parent's
334
+ * context it never sees the authoritative ids — it depends on the model
335
+ * re-typing them into `message`, which weak models botch, causing
336
+ * schema/permission rejections and retry loops. Forwarding it here is the
337
+ * regression this seam guards.
338
+ */
339
+ export function buildSubAgentRunInput(message, threadId, resourceId, parentContext) {
340
+ return { message, threadId, resourceId, context: parentContext };
341
+ }
342
+ export async function buildToolDefs(params, agentSessionMap, resourceId, agentName, packageName, streamContext, aiMiddlewares, agentMode,
343
+ // The parent run's `context` (the "Current context" identifier block). A
344
+ // delegated sub-agent's tool-call input schema only carries { message,
345
+ // session }, so without inheriting this the sub-agent never sees the
346
+ // authoritative ids (organizationId, project/stage ids) — it depends on the
347
+ // model re-typing them into `message`, which weak models botch, causing
348
+ // schema/permission rejections and retry loops. Forward it so the sub-agent
349
+ // gets the same context block in its instructions.
350
+ parentContext) {
329
351
  const singletonServices = getSingletonServices();
330
352
  const tools = [];
331
353
  const missingRpcs = [];
@@ -505,7 +527,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
505
527
  subChannel.send(event);
506
528
  },
507
529
  };
508
- const resultText = await streamAIAgent(subAgentName, { message, threadId, resourceId }, effectiveChannel, params, agentSessionMap, streamContext.options);
530
+ const resultText = await streamAIAgent(subAgentName, buildSubAgentRunInput(message, threadId, resourceId, parentContext), effectiveChannel, params, agentSessionMap, streamContext.options);
509
531
  if (subChannel.approvals.length > 0) {
510
532
  return {
511
533
  [APPROVAL_REQUIRED]: true,
@@ -525,7 +547,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
525
547
  return resultText;
526
548
  }
527
549
  // No stream context: sub-agent runs non-streaming
528
- const result = await runAIAgent(subAgentName, { message, threadId, resourceId }, params, agentSessionMap);
550
+ const result = await runAIAgent(subAgentName, buildSubAgentRunInput(message, threadId, resourceId, parentContext), params, agentSessionMap);
529
551
  if (result.status === 'suspended' &&
530
552
  result.pendingApprovals?.length) {
531
553
  return {
@@ -731,7 +753,7 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
731
753
  const allMessages = [...contextMessages, ...messages, userMessage];
732
754
  const trimmedMessages = trimMessages(allMessages);
733
755
  const aiMiddlewares = agent.aiMiddleware ?? [];
734
- const { tools, missingRpcs } = await buildToolDefs(params, agentSessionMap, input.resourceId, resolvedName, packageName, streamContext, aiMiddlewares, agent.agentMode);
756
+ const { tools, missingRpcs } = await buildToolDefs(params, agentSessionMap, input.resourceId, resolvedName, packageName, streamContext, aiMiddlewares, agent.agentMode, input.context);
735
757
  let instructions = await buildInstructions(resolvedName, packageName);
736
758
  if (input.context) {
737
759
  instructions = `${instructions}\n\nCurrent context (use these identifiers directly in tool calls — do not ask the user for them):\n${input.context}`;
@@ -1,4 +1,4 @@
1
- export type { ConfigValidationResult, CoreQueueWorker, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueJob, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
1
+ export type { ConfigValidationResult, CoreQueueWorker, GroupConcurrencyConfig, JobGroup, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueJob, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
2
2
  export { wireQueueWorker, runQueueJob, getQueueWorkers, removeQueueWorker, QueueJobDiscardedError, QueueJobFailedError, } from './queue-runner.js';
3
3
  export { validateWorkerConfig } from './validate-worker-config.js';
4
4
  export type { QueueConfigMapping } from './validate-worker-config.js';
@@ -30,6 +30,34 @@ export interface PikkuWorkerConfig {
30
30
  maxStalledCount?: number;
31
31
  /** Condition to start processor at instance creation */
32
32
  autorun?: boolean;
33
+ /**
34
+ * Cap how many jobs of any one group ({@link JobOptions.group}) may run at
35
+ * once, so a single group can't occupy the whole worker. Lets one shared
36
+ * queue stay fair across producers instead of splitting it into one queue
37
+ * per producer — which multiplies polling cost on pull-based backends.
38
+ * Must not exceed {@link batchSize}.
39
+ */
40
+ groupConcurrency?: number | GroupConcurrencyConfig;
41
+ }
42
+ /**
43
+ * Per-group concurrency limits, optionally varied by tier so slow groups can
44
+ * be allowed more (or fewer) slots than the default.
45
+ */
46
+ export interface GroupConcurrencyConfig {
47
+ /** Limit applied to any group without a matching tier */
48
+ default: number;
49
+ /** Per-tier overrides, keyed by {@link JobGroup.tier} */
50
+ tiers?: Record<string, number>;
51
+ }
52
+ /**
53
+ * Fairness key for a job. Jobs sharing an `id` count against the same
54
+ * {@link PikkuWorkerConfig.groupConcurrency} limit.
55
+ */
56
+ export interface JobGroup {
57
+ /** Group this job belongs to (e.g. a workflow name) */
58
+ id: string;
59
+ /** Optional tier selecting a per-tier limit */
60
+ tier?: string;
33
61
  }
34
62
  /**
35
63
  * Configuration for individual jobs - how jobs behave
@@ -106,6 +134,8 @@ export interface JobOptions {
106
134
  jobId?: string;
107
135
  /** Pikku user ID to propagate to the queue worker for credential resolution */
108
136
  pikkuUserId?: string;
137
+ /** Fairness key — counts against the worker's {@link PikkuWorkerConfig.groupConcurrency} */
138
+ group?: JobGroup;
109
139
  }
110
140
  /**
111
141
  * Queue provider interface for job publishing operations
@@ -11,5 +11,5 @@ export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGr
11
11
  export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-validation.js';
12
12
  export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
13
13
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
14
- export type { WorkflowService, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
14
+ export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
15
15
  export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './workflow.types.js';
@@ -1,10 +1,10 @@
1
1
  import type { SerializedError } from '../../types/core.types.js';
2
- import type { ApprovalOutcome, PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
2
+ import type { ApprovalOutcome, PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowQueueOptions, WorkflowStepOptions } from './workflow.types.js';
3
3
  import type { WorkflowService } from '../../services/workflow-service.js';
4
4
  import type { ScenarioActors } from '../../services/scenario-actors-service.js';
5
5
  import { PikkuError } from '../../errors/error-handler.js';
6
6
  import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
7
- import type { JobOptions } from '../queue/queue.types.js';
7
+ import type { GroupConcurrencyConfig, JobGroup, JobOptions } from '../queue/queue.types.js';
8
8
  /**
9
9
  * Default number of retries for a workflow step when none is specified. The
10
10
  * workflow — not the queue — owns retry policy; a step inherits this unless it
@@ -98,10 +98,13 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
98
98
  private runActors;
99
99
  protected get logger(): import("../../services/logger.js").Logger;
100
100
  protected mirror?: WorkflowRunMirror;
101
+ protected readonly queueStrategy: 'per-workflow' | 'shared-groups';
102
+ protected readonly queueConcurrency: number;
103
+ protected readonly queueGroupConcurrency: number | GroupConcurrencyConfig;
101
104
  constructor(options?: {
102
105
  wireQueues?: boolean;
103
106
  mirror?: WorkflowRunMirror;
104
- });
107
+ } & WorkflowQueueOptions);
105
108
  private safeMirror;
106
109
  /**
107
110
  * Wire the queue-based orchestrator/step/sleeper workers.
@@ -519,4 +522,14 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
519
522
  */
520
523
  protected getOrchestratorQueueName(workflowName?: string): string;
521
524
  protected getStepWorkerQueueName(rpcName?: string): string;
525
+ /**
526
+ * Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
527
+ * name already isolates workflows, so no group is needed — returning one
528
+ * anyway would cap a workflow inside its own dedicated queue.
529
+ *
530
+ * The tier repeats the id so a workflow can be given its own limit purely
531
+ * from config, with no per-workflow wiring; an unmatched tier falls back to
532
+ * the default limit.
533
+ */
534
+ protected getJobGroup(id?: string): JobGroup | undefined;
522
535
  }
@@ -186,9 +186,15 @@ export class PikkuWorkflowService {
186
186
  return getSingletonServices()?.logger;
187
187
  }
188
188
  mirror;
189
+ queueStrategy;
190
+ queueConcurrency;
191
+ queueGroupConcurrency;
189
192
  constructor(options = {}) {
190
193
  const wireQueues = options.wireQueues ?? true;
191
194
  this.mirror = options.mirror;
195
+ this.queueStrategy = options.queueStrategy ?? 'per-workflow';
196
+ this.queueConcurrency = options.queueConcurrency ?? 20;
197
+ this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2;
192
198
  if (wireQueues) {
193
199
  this.wireQueueWorkers();
194
200
  }
@@ -226,21 +232,32 @@ export class PikkuWorkflowService {
226
232
  outputSchemaName: null,
227
233
  });
228
234
  const queueMeta = pikkuState(null, 'queue', 'meta');
229
- const registerWorkflowFunc = (funcId, func, queueName) => {
235
+ const registerWorkflowFunc = (funcId, func, queueName, config) => {
230
236
  if (functions.has(funcId))
231
237
  return;
232
238
  addFunction(funcId, func);
233
239
  if (!queueMeta[queueName]) {
234
240
  queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName };
235
241
  }
236
- wireQueueWorker({ name: queueName, func });
242
+ wireQueueWorker({ name: queueName, func, config });
237
243
  if (!functionsMeta[funcId]) {
238
244
  functionsMeta[funcId] = mkMeta(funcId);
239
245
  }
240
246
  };
247
+ // Under 'shared-groups' every workflow runs through these two queues and is
248
+ // kept from hogging them by the per-group cap, so the per-workflow queues
249
+ // below are left unconsumed — one set of pollers for the whole system
250
+ // instead of one per workflow.
251
+ const sharedGroups = this.queueStrategy === 'shared-groups';
252
+ const sharedQueueConfig = sharedGroups
253
+ ? {
254
+ batchSize: this.queueConcurrency,
255
+ groupConcurrency: this.queueGroupConcurrency,
256
+ }
257
+ : undefined;
241
258
  // Register shared queue workers for monolith deployments
242
- registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator');
243
- registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker');
259
+ registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator', sharedQueueConfig);
260
+ registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker', sharedQueueConfig);
244
261
  // Register per-workflow queue workers (root + addon packages)
245
262
  const registerQueueWorkers = (queueMeta) => {
246
263
  for (const [queueName, meta] of Object.entries(queueMeta)) {
@@ -254,16 +271,32 @@ export class PikkuWorkflowService {
254
271
  }
255
272
  }
256
273
  };
257
- registerQueueWorkers(pikkuState(null, 'queue', 'meta'));
258
- const addons = pikkuState(null, 'addons', 'packages');
259
- if (addons) {
260
- for (const [, addon] of addons) {
261
- const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta');
262
- if (addonQueueMeta) {
263
- registerQueueWorkers(addonQueueMeta);
274
+ if (!sharedGroups) {
275
+ registerQueueWorkers(pikkuState(null, 'queue', 'meta'));
276
+ const addons = pikkuState(null, 'addons', 'packages');
277
+ if (addons) {
278
+ for (const [, addon] of addons) {
279
+ const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta');
280
+ if (addonQueueMeta) {
281
+ registerQueueWorkers(addonQueueMeta);
282
+ }
264
283
  }
265
284
  }
266
285
  }
286
+ // Workflows exist but no per-workflow orchestrator queue was registered:
287
+ // the generated queue meta never reached the runtime (most often the
288
+ // bootstrap doesn't import the queue-workers meta, so `queue.meta` is
289
+ // empty). Everything still "works" — dispatch silently falls back to the
290
+ // single shared orchestrator queue — but the isolation is gone: one slow
291
+ // workflow step head-of-line-blocks every other workflow behind it. That
292
+ // is invisible until a queue starves, so say so loudly at wiring time.
293
+ const workflowCount = Object.keys(pikkuState(null, 'workflows', 'meta') ?? {}).length;
294
+ const perWorkflowQueues = Object.keys(queueMeta).filter((name) => name.startsWith('wf-orchestrator-')).length;
295
+ if (workflowCount > 0 && perWorkflowQueues === 0) {
296
+ this.logger?.warn?.(`[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
297
+ `All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
298
+ `Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`);
299
+ }
267
300
  if (!functions.has('pikkuWorkflowSleeper')) {
268
301
  addFunction('pikkuWorkflowSleeper', {
269
302
  func: pikkuWorkflowSleeperFunc,
@@ -513,7 +546,10 @@ export class PikkuWorkflowService {
513
546
  // dispatch/infra failure recover: the job is rethrown and retried instead of
514
547
  // the run hanging. Passing `attempts` per-job overrides the queue default, so
515
548
  // this holds even when the orchestrator queue is configured `retry_limit 0`.
516
- await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, this.resolveStepJobOptions());
549
+ await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
550
+ ...this.resolveStepJobOptions(),
551
+ group: this.getJobGroup(workflowName),
552
+ });
517
553
  }
518
554
  /**
519
555
  * Resolve a step's retry policy into queue job options. The workflow is the
@@ -538,7 +574,12 @@ export class PikkuWorkflowService {
538
574
  }
539
575
  async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
540
576
  const queueService = this.verifyQueueService();
541
- await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
577
+ await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
578
+ ...this.resolveStepJobOptions(stepOptions),
579
+ // Group by step function, mirroring how per-step queues split them —
580
+ // one slow step function can't monopolise the shared step worker.
581
+ group: this.getJobGroup(rpcName),
582
+ });
542
583
  }
543
584
  /**
544
585
  * Execute a workflow sleep step completion
@@ -560,7 +601,12 @@ export class PikkuWorkflowService {
560
601
  const run = await this.getRun(runId);
561
602
  workflowName = run?.workflow;
562
603
  }
563
- await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined);
604
+ await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
605
+ ...(retryDelay
606
+ ? { delay: getDurationInMilliseconds(retryDelay) }
607
+ : undefined),
608
+ group: this.getJobGroup(workflowName),
609
+ });
564
610
  }
565
611
  /**
566
612
  * Dispatch a workflow step to be executed asynchronously.
@@ -592,7 +638,10 @@ export class PikkuWorkflowService {
592
638
  throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
593
639
  }
594
640
  try {
595
- await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
641
+ await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
642
+ ...this.resolveStepJobOptions(stepOptions),
643
+ group: this.getJobGroup(rpcName),
644
+ });
596
645
  }
597
646
  catch (cause) {
598
647
  // The queue is down/unreachable — NOT a step failure. Surface it as a
@@ -1430,7 +1479,11 @@ export class PikkuWorkflowService {
1430
1479
  const run = await this.getRun(runId);
1431
1480
  if (!run?.workflow)
1432
1481
  return;
1433
- await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, { ...this.resolveStepJobOptions(), delay });
1482
+ await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
1483
+ ...this.resolveStepJobOptions(),
1484
+ delay,
1485
+ group: this.getJobGroup(run.workflow),
1486
+ });
1434
1487
  }
1435
1488
  catch (error) {
1436
1489
  this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
@@ -1712,7 +1765,7 @@ export class PikkuWorkflowService {
1712
1765
  * queues — but it produces to them — so registrations would miss them.
1713
1766
  */
1714
1767
  getOrchestratorQueueName(workflowName) {
1715
- if (workflowName) {
1768
+ if (workflowName && this.queueStrategy !== 'shared-groups') {
1716
1769
  const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`;
1717
1770
  const meta = pikkuState(null, 'queue', 'meta');
1718
1771
  if (meta[perWorkflow]) {
@@ -1722,7 +1775,7 @@ export class PikkuWorkflowService {
1722
1775
  return this.getConfig().orchestratorQueueName;
1723
1776
  }
1724
1777
  getStepWorkerQueueName(rpcName) {
1725
- if (rpcName) {
1778
+ if (rpcName && this.queueStrategy !== 'shared-groups') {
1726
1779
  const perStep = `wf-step-${toKebab(rpcName)}`;
1727
1780
  const meta = pikkuState(null, 'queue', 'meta');
1728
1781
  if (meta[perStep]) {
@@ -1731,4 +1784,19 @@ export class PikkuWorkflowService {
1731
1784
  }
1732
1785
  return this.getConfig().stepWorkerQueueName;
1733
1786
  }
1787
+ /**
1788
+ * Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
1789
+ * name already isolates workflows, so no group is needed — returning one
1790
+ * anyway would cap a workflow inside its own dedicated queue.
1791
+ *
1792
+ * The tier repeats the id so a workflow can be given its own limit purely
1793
+ * from config, with no per-workflow wiring; an unmatched tier falls back to
1794
+ * the default limit.
1795
+ */
1796
+ getJobGroup(id) {
1797
+ if (!id || this.queueStrategy !== 'shared-groups') {
1798
+ return undefined;
1799
+ }
1800
+ return { id, tier: id };
1801
+ }
1734
1802
  }
@@ -1,5 +1,6 @@
1
1
  import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
2
2
  import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
3
+ import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
3
4
  export type { WorkflowService } from '../../services/workflow-service.js';
4
5
  export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
5
6
  import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
@@ -18,6 +19,43 @@ export interface WorkflowServiceConfig {
18
19
  stepWorkerQueueName: string;
19
20
  sleeperRPCName: string;
20
21
  }
22
+ /**
23
+ * How a workflow service spreads its jobs across queues.
24
+ *
25
+ * Passed to the service constructor rather than read from `config.workflow`:
26
+ * the queues are wired during construction, before singleton services (and so
27
+ * before `config`) exist.
28
+ */
29
+ export interface WorkflowQueueOptions {
30
+ /**
31
+ * - `'per-workflow'` (default) — each workflow gets its own
32
+ * `wf-orchestrator-*` / `wf-step-*` queue. Complete isolation, and it's
33
+ * what lets serverless providers deploy one unit per workflow. Costs one
34
+ * set of pollers per queue, which adds up on pull-based backends.
35
+ * - `'shared-groups'` — every workflow shares the orchestrator/step-worker
36
+ * queues and stays isolated via {@link queueGroupConcurrency}, so no
37
+ * workflow can occupy more than its share. One set of pollers total.
38
+ * Only for single-process (monolith) runtimes; a per-unit serverless
39
+ * deploy needs the per-workflow queues to route to its units.
40
+ */
41
+ queueStrategy?: 'per-workflow' | 'shared-groups';
42
+ /**
43
+ * Total concurrent workflow jobs per node under `'shared-groups'`.
44
+ * Defaults to 20.
45
+ */
46
+ queueConcurrency?: number;
47
+ /**
48
+ * How many jobs of one workflow may run at once under `'shared-groups'`.
49
+ * Defaults to 2. Tiers are keyed by workflow name, so a specific workflow
50
+ * can be given its own limit without any extra wiring.
51
+ *
52
+ * Note tiers can only lower a workflow's limit, not raise it above
53
+ * `default`: the backend's pre-fetch exclusion is applied per group using
54
+ * `default` alone. To give one workflow more room, raise `default` and
55
+ * restrict the others by tier.
56
+ */
57
+ queueGroupConcurrency?: number | GroupConcurrencyConfig;
58
+ }
21
59
  export interface WorkflowPlannedStep {
22
60
  /** Durable step key — matches the runtime step name stored in the DB */
23
61
  stepName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.67",
3
+ "version": "0.12.69",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -2,7 +2,7 @@ import { describe, test, beforeEach } from 'node:test'
2
2
  import assert from 'node:assert'
3
3
  import { InMemoryWorkflowService } from './in-memory-workflow-service.js'
4
4
  import { getQueueWorkers } from '../wirings/queue/queue-runner.js'
5
- import { pikkuState } from '../pikku-state.js'
5
+ import { pikkuState, resetPikkuState } from '../pikku-state.js'
6
6
 
7
7
  let service: InMemoryWorkflowService
8
8
 
@@ -390,5 +390,54 @@ describe('InMemoryWorkflowService', () => {
390
390
  assert.strictEqual(getQueueWorkers().has(stepQueue), true)
391
391
  assert.strictEqual(getQueueWorkers().has(orchQueue), true)
392
392
  })
393
+
394
+ test('shared-groups leaves per-workflow queues unconsumed', () => {
395
+ const orchQueue = 'wf-orchestrator-shared-strategy-check'
396
+
397
+ const queueMeta = pikkuState(null, 'queue', 'meta')
398
+ queueMeta[orchQueue] = {
399
+ name: orchQueue,
400
+ pikkuFuncId: 'pikkuWorkflowOrchestrator:sharedStrategyCheck',
401
+ }
402
+
403
+ const ws = new InMemoryWorkflowService({
404
+ queueStrategy: 'shared-groups',
405
+ })
406
+ ws.wireQueueWorkers()
407
+
408
+ // The whole point: one set of pollers, not one per workflow.
409
+ assert.strictEqual(getQueueWorkers().has(orchQueue), false)
410
+ assert.strictEqual(
411
+ getQueueWorkers().has('pikku-workflow-orchestrator'),
412
+ true
413
+ )
414
+ })
415
+
416
+ test('shared-groups caps how much of the shared queue one workflow takes', () => {
417
+ // registerWorkflowFunc is first-write-wins, so start from clean state
418
+ // rather than inheriting the shared queues an earlier test registered.
419
+ resetPikkuState()
420
+ const ws = new InMemoryWorkflowService({
421
+ queueStrategy: 'shared-groups',
422
+ queueConcurrency: 20,
423
+ queueGroupConcurrency: 3,
424
+ })
425
+ ws.wireQueueWorkers()
426
+
427
+ const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
428
+ assert.deepStrictEqual(worker?.config, {
429
+ batchSize: 20,
430
+ groupConcurrency: 3,
431
+ })
432
+ })
433
+
434
+ test('per-workflow (the default) leaves the shared queues unconstrained', () => {
435
+ resetPikkuState()
436
+ const ws = new InMemoryWorkflowService()
437
+ ws.wireQueueWorkers()
438
+
439
+ const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
440
+ assert.strictEqual(worker?.config, undefined)
441
+ })
393
442
  })
394
443
  })
@@ -4,6 +4,7 @@ import { isExpectedError } from '../errors/error-handler.js'
4
4
  import type { SerializedError } from '../types/core.types.js'
5
5
  import type {
6
6
  WorkflowPlannedStep,
7
+ WorkflowQueueOptions,
7
8
  WorkflowRun,
8
9
  WorkflowRunService,
9
10
  WorkflowRunWire,
@@ -39,8 +40,8 @@ export class InMemoryWorkflowService
39
40
  extends PikkuWorkflowService
40
41
  implements WorkflowRunService
41
42
  {
42
- constructor() {
43
- super({ wireQueues: false })
43
+ constructor(options: WorkflowQueueOptions = {}) {
44
+ super({ ...options, wireQueues: false })
44
45
  }
45
46
 
46
47
  private sleepTimers = new Set<ReturnType<typeof setTimeout>>()