@pikku/core 0.12.37 → 0.12.39
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 +113 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +27 -1
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -29,9 +29,19 @@ const resolveWorkflowMeta = (name) => {
|
|
|
29
29
|
};
|
|
30
30
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
31
31
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
32
|
-
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
32
|
+
import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
|
|
33
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
34
34
|
import { ChildWorkflowStartedException } from './graph/graph-runner.js';
|
|
35
|
+
import { deriveInvocationId } from './workflow-invocation-id.js';
|
|
36
|
+
import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
37
|
+
/**
|
|
38
|
+
* Default number of retries for a workflow step when none is specified. The
|
|
39
|
+
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
40
|
+
* sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
|
|
41
|
+
* so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
|
|
42
|
+
* out by default; safe because every step gets a stable `invocationId` to dedupe on.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_STEP_RETRIES = 5;
|
|
35
45
|
/**
|
|
36
46
|
* Exception thrown when workflow needs to pause for async step
|
|
37
47
|
*/
|
|
@@ -71,6 +81,24 @@ export class WorkflowSuspendedException extends Error {
|
|
|
71
81
|
this.name = 'WorkflowSuspendedException';
|
|
72
82
|
}
|
|
73
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Thrown when a step (or the orchestrator) could not be enqueued — the queue
|
|
86
|
+
* itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
|
|
87
|
+
* This is transient infrastructure failure: the run is left untouched (the step
|
|
88
|
+
* stays `pending`, the run stays running) and the orchestrator job is rethrown
|
|
89
|
+
* so the queue redelivers it and the workflow replays from its snapshot. Treat
|
|
90
|
+
* it as non-terminal — never mark the run `failed` for it.
|
|
91
|
+
*/
|
|
92
|
+
export class WorkflowDispatchException extends Error {
|
|
93
|
+
runId;
|
|
94
|
+
stepName;
|
|
95
|
+
constructor(runId, stepName, options) {
|
|
96
|
+
super(`Failed to dispatch workflow step '${stepName}' (run ${runId})`, options);
|
|
97
|
+
this.runId = runId;
|
|
98
|
+
this.stepName = stepName;
|
|
99
|
+
this.name = 'WorkflowDispatchException';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
74
102
|
/**
|
|
75
103
|
* Error class for workflow not found
|
|
76
104
|
*/
|
|
@@ -266,6 +294,7 @@ export class PikkuWorkflowService {
|
|
|
266
294
|
status: step.status,
|
|
267
295
|
startedAt: step.runningAt ?? step.createdAt,
|
|
268
296
|
completedAt: step.succeededAt ?? step.failedAt,
|
|
297
|
+
attempts: step.attemptCount,
|
|
269
298
|
});
|
|
270
299
|
}
|
|
271
300
|
}
|
|
@@ -275,6 +304,7 @@ export class PikkuWorkflowService {
|
|
|
275
304
|
duration: s.startedAt && s.completedAt
|
|
276
305
|
? s.completedAt.getTime() - s.startedAt.getTime()
|
|
277
306
|
: undefined,
|
|
307
|
+
attempts: s.attempts,
|
|
278
308
|
}));
|
|
279
309
|
return {
|
|
280
310
|
id: run.id,
|
|
@@ -290,6 +320,30 @@ export class PikkuWorkflowService {
|
|
|
290
320
|
: undefined,
|
|
291
321
|
};
|
|
292
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Build the run's time-travel event stream from durable history.
|
|
325
|
+
* @param id - Run ID
|
|
326
|
+
* @returns Ordered timeline, or null if the run doesn't exist
|
|
327
|
+
*/
|
|
328
|
+
async getRunTimeline(id) {
|
|
329
|
+
const run = await this.getRun(id);
|
|
330
|
+
if (!run)
|
|
331
|
+
return null;
|
|
332
|
+
return buildRunTimeline(await this.getRunHistory(id));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Reconstruct the run's state at a point in its timeline.
|
|
336
|
+
* @param id - Run ID
|
|
337
|
+
* @param at - A seq index (inclusive) or a Date (inclusive); omit for the
|
|
338
|
+
* final state.
|
|
339
|
+
* @returns Reconstructed state, or null if the run doesn't exist
|
|
340
|
+
*/
|
|
341
|
+
async reconstructRunStateAt(id, at) {
|
|
342
|
+
const timeline = await this.getRunTimeline(id);
|
|
343
|
+
if (!timeline)
|
|
344
|
+
return null;
|
|
345
|
+
return reconstructStateAt(timeline, at ?? timeline.length - 1);
|
|
346
|
+
}
|
|
293
347
|
/**
|
|
294
348
|
* Update workflow run status
|
|
295
349
|
* @param id - Run ID
|
|
@@ -309,8 +363,8 @@ export class PikkuWorkflowService {
|
|
|
309
363
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
310
364
|
* @returns Step state with generated stepId
|
|
311
365
|
*/
|
|
312
|
-
async insertStepState(runId, stepName, rpcName, data, stepOptions) {
|
|
313
|
-
const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
|
|
366
|
+
async insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
367
|
+
const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName);
|
|
314
368
|
await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
315
369
|
return step;
|
|
316
370
|
}
|
|
@@ -363,6 +417,7 @@ export class PikkuWorkflowService {
|
|
|
363
417
|
message: error.message,
|
|
364
418
|
stack: error.stack,
|
|
365
419
|
code: error.code,
|
|
420
|
+
expected: isExpectedError(error),
|
|
366
421
|
};
|
|
367
422
|
await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
|
|
368
423
|
}
|
|
@@ -423,29 +478,38 @@ export class PikkuWorkflowService {
|
|
|
423
478
|
const run = await this.getRun(runId);
|
|
424
479
|
workflowName = run?.workflow;
|
|
425
480
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
481
|
+
// Carry an explicit retry policy on the orchestrator job too. Orchestrator
|
|
482
|
+
// runs are idempotent (they replay from the snapshot, returning cached step
|
|
483
|
+
// results), so redelivery is always safe — and it's what lets a transient
|
|
484
|
+
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
485
|
+
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
486
|
+
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
487
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, this.resolveStepJobOptions());
|
|
429
488
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
489
|
+
/**
|
|
490
|
+
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
491
|
+
* sole source of truth for retries: an explicitly-set `retries` (including 0)
|
|
492
|
+
* is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
|
|
493
|
+
* and we ALWAYS pass `attempts` so the queue can never fall back to its own
|
|
494
|
+
* default — which would re-run a step the workflow said not to retry. Backoff
|
|
495
|
+
* defaults to exponential whenever there's at least one retry, so retries ride
|
|
496
|
+
* out a transient outage instead of firing instantly.
|
|
497
|
+
*/
|
|
498
|
+
resolveStepJobOptions(stepOptions) {
|
|
499
|
+
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
|
|
433
500
|
const retryDelay = stepOptions?.retryDelay;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
: undefined,
|
|
447
|
-
}
|
|
448
|
-
: undefined);
|
|
501
|
+
const backoff = typeof retryDelay === 'number'
|
|
502
|
+
? { type: 'fixed', delay: retryDelay }
|
|
503
|
+
: retryDelay === 'exponential'
|
|
504
|
+
? 'exponential'
|
|
505
|
+
: retries > 0
|
|
506
|
+
? 'exponential'
|
|
507
|
+
: undefined;
|
|
508
|
+
return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
|
|
509
|
+
}
|
|
510
|
+
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
511
|
+
const queueService = this.verifyQueueService();
|
|
512
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
|
|
449
513
|
}
|
|
450
514
|
/**
|
|
451
515
|
* Execute a workflow sleep step completion
|
|
@@ -483,7 +547,7 @@ export class PikkuWorkflowService {
|
|
|
483
547
|
* @returns true if dispatch was async (caller should pause), false to fall
|
|
484
548
|
* through to the inline execution path.
|
|
485
549
|
*/
|
|
486
|
-
async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
|
|
550
|
+
async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
487
551
|
// Step execution is decided purely by the function's `inline` flag (default
|
|
488
552
|
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
489
553
|
// the queue. The run-level inline state is intentionally NOT consulted
|
|
@@ -505,21 +569,16 @@ export class PikkuWorkflowService {
|
|
|
505
569
|
getSingletonServices()?.logger.warn(`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`);
|
|
506
570
|
return false;
|
|
507
571
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
? { type: 'fixed', delay: retryDelay }
|
|
519
|
-
: retryDelay === 'exponential'
|
|
520
|
-
? 'exponential'
|
|
521
|
-
: undefined,
|
|
522
|
-
});
|
|
572
|
+
try {
|
|
573
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), this.resolveStepJobOptions(stepOptions));
|
|
574
|
+
}
|
|
575
|
+
catch (cause) {
|
|
576
|
+
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
577
|
+
// transient dispatch error so the caller leaves the step `pending` and the
|
|
578
|
+
// orchestrator job is retried (replayed from snapshot) rather than the run
|
|
579
|
+
// being marked failed. `add` already throws on failure in every adapter.
|
|
580
|
+
throw new WorkflowDispatchException(runId, stepName, { cause });
|
|
581
|
+
}
|
|
523
582
|
return true;
|
|
524
583
|
}
|
|
525
584
|
/**
|
|
@@ -587,13 +646,25 @@ export class PikkuWorkflowService {
|
|
|
587
646
|
catch (error) {
|
|
588
647
|
if (error.name !== 'WorkflowAsyncException' &&
|
|
589
648
|
error.name !== 'WorkflowCancelledException' &&
|
|
590
|
-
error.name !== 'WorkflowSuspendedException'
|
|
649
|
+
error.name !== 'WorkflowSuspendedException' &&
|
|
650
|
+
// Transient queue failure — leave the run resumable, don't fail it.
|
|
651
|
+
error.name !== 'WorkflowDispatchException') {
|
|
591
652
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
592
653
|
name: error.name,
|
|
593
654
|
message: error.message,
|
|
594
655
|
stack: error.stack,
|
|
595
656
|
});
|
|
596
|
-
|
|
657
|
+
// An expected failure (a PikkuError, e.g. a build gate tripping) —
|
|
658
|
+
// its message is the whole story, so don't dump the stack. The
|
|
659
|
+
// `expected` flag survives the step-boundary rehydration that strips
|
|
660
|
+
// the class. Anything else is an uncaught/unexpected error: log it in
|
|
661
|
+
// full so the trace is there to debug.
|
|
662
|
+
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, isExpectedError(error) ? error.message : error);
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
666
|
+
// Rethrow so the caller (poll loop / starter) sees the transient
|
|
667
|
+
// failure; the run stays running and can be resumed.
|
|
597
668
|
throw error;
|
|
598
669
|
}
|
|
599
670
|
}
|
|
@@ -626,7 +697,48 @@ export class PikkuWorkflowService {
|
|
|
626
697
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
627
698
|
}
|
|
628
699
|
}
|
|
700
|
+
// Per-run, per-replay ordinal counters (runId → stepName → count).
|
|
701
|
+
stepOrdinals = new Map();
|
|
702
|
+
// Previous step key reached in the current DSL walk (runId → stepName), so a
|
|
703
|
+
// new step records where it came from. Rebuilt each replay alongside ordinals.
|
|
704
|
+
stepLineage = new Map();
|
|
705
|
+
resetStepOrdinals(runId) {
|
|
706
|
+
this.stepOrdinals.set(runId, new Map());
|
|
707
|
+
this.stepLineage.delete(runId);
|
|
708
|
+
}
|
|
709
|
+
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
710
|
+
lastStepName(runId) {
|
|
711
|
+
return this.stepLineage.get(runId);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
715
|
+
* bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
|
|
716
|
+
* repeats — so the same literal step name can be invoked multiple times without
|
|
717
|
+
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
718
|
+
*/
|
|
719
|
+
nextStepKey(runId, logicalStepName) {
|
|
720
|
+
let perRun = this.stepOrdinals.get(runId);
|
|
721
|
+
if (!perRun) {
|
|
722
|
+
perRun = new Map();
|
|
723
|
+
this.stepOrdinals.set(runId, perRun);
|
|
724
|
+
}
|
|
725
|
+
const ordinal = perRun.get(logicalStepName) ?? 0;
|
|
726
|
+
perRun.set(logicalStepName, ordinal + 1);
|
|
727
|
+
const stepName = ordinal === 0 ? logicalStepName : `${logicalStepName}#${ordinal}`;
|
|
728
|
+
this.stepLineage.set(runId, stepName);
|
|
729
|
+
return stepName;
|
|
730
|
+
}
|
|
629
731
|
async runWorkflowJob(runId, rpcService) {
|
|
732
|
+
// Fresh ordinal counters per replay so step keys are deterministic.
|
|
733
|
+
this.resetStepOrdinals(runId);
|
|
734
|
+
try {
|
|
735
|
+
await this.runWorkflowJobInner(runId, rpcService);
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
this.stepOrdinals.delete(runId);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
async runWorkflowJobInner(runId, rpcService) {
|
|
630
742
|
const run = await this.getRun(runId);
|
|
631
743
|
if (!run) {
|
|
632
744
|
throw new WorkflowRunNotFoundError(runId);
|
|
@@ -799,10 +911,22 @@ export class PikkuWorkflowService {
|
|
|
799
911
|
const workflowMeta = meta[run.workflow];
|
|
800
912
|
const isGraphWorkflow = workflowMeta?.source === 'graph' ||
|
|
801
913
|
workflowMeta?.source === 'dynamic-workflow';
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
914
|
+
// Map the physical step key back to its logical node: a revisit instance
|
|
915
|
+
// is `node#N` (ordinal), which isn't a literal key in `nodes`.
|
|
916
|
+
let graphNodeId;
|
|
917
|
+
if (isGraphWorkflow && workflowMeta?.nodes) {
|
|
918
|
+
if (stepName in workflowMeta.nodes) {
|
|
919
|
+
graphNodeId = stepName;
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
const hash = stepName.lastIndexOf('#');
|
|
923
|
+
const base = hash > 0 ? stepName.slice(0, hash) : undefined;
|
|
924
|
+
if (base && base in workflowMeta.nodes)
|
|
925
|
+
graphNodeId = base;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (graphNodeId) {
|
|
929
|
+
result = await executeGraphStep(this, rpcService, runId, stepState.stepId, graphNodeId, rpcName, data, run.workflow);
|
|
806
930
|
}
|
|
807
931
|
else {
|
|
808
932
|
// Check if rpcName refers to a sub-workflow
|
|
@@ -833,13 +957,7 @@ export class PikkuWorkflowService {
|
|
|
833
957
|
}
|
|
834
958
|
}
|
|
835
959
|
else {
|
|
836
|
-
result = await
|
|
837
|
-
workflowStep: {
|
|
838
|
-
runId,
|
|
839
|
-
stepId: stepState.stepId,
|
|
840
|
-
attemptCount: stepState.attemptCount,
|
|
841
|
-
},
|
|
842
|
-
});
|
|
960
|
+
result = await this.invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService);
|
|
843
961
|
}
|
|
844
962
|
}
|
|
845
963
|
// Store result and mark succeeded
|
|
@@ -862,7 +980,7 @@ export class PikkuWorkflowService {
|
|
|
862
980
|
}
|
|
863
981
|
// Store error and mark failed
|
|
864
982
|
await this.setStepError(stepState.stepId, error);
|
|
865
|
-
const maxAttempts = (stepState.retries ??
|
|
983
|
+
const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1;
|
|
866
984
|
const retriesExhausted = stepState.attemptCount >= maxAttempts;
|
|
867
985
|
if (retriesExhausted) {
|
|
868
986
|
// No more retries - resume orchestrator to mark workflow as failed
|
|
@@ -888,6 +1006,13 @@ export class PikkuWorkflowService {
|
|
|
888
1006
|
error.name === 'WorkflowSuspendedException') {
|
|
889
1007
|
return;
|
|
890
1008
|
}
|
|
1009
|
+
if (error.name === 'WorkflowDispatchException') {
|
|
1010
|
+
// Transient: the queue was unreachable, not a workflow failure. Leave the
|
|
1011
|
+
// run running and rethrow so the orchestrator job is redelivered and the
|
|
1012
|
+
// workflow replays from its snapshot. Do NOT mark the run failed.
|
|
1013
|
+
getSingletonServices().logger.warn(`Workflow run ${runId} could not dispatch a step (queue unavailable); leaving run for orchestrator retry`, error);
|
|
1014
|
+
throw error;
|
|
1015
|
+
}
|
|
891
1016
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
892
1017
|
message: error.message,
|
|
893
1018
|
stack: error.stack,
|
|
@@ -902,7 +1027,75 @@ export class PikkuWorkflowService {
|
|
|
902
1027
|
}
|
|
903
1028
|
return getSingletonServices().queueService;
|
|
904
1029
|
}
|
|
905
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* Invoke a step's RPC with the workflow-step wire (step identity + provenance).
|
|
1032
|
+
* Identical for the queue executor and the inline executor — the only thing
|
|
1033
|
+
* that differs between transports is who calls it, not the call itself.
|
|
1034
|
+
*/
|
|
1035
|
+
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
|
|
1036
|
+
return rpcService.rpcWithWire(rpcName, data, {
|
|
1037
|
+
workflowStep: {
|
|
1038
|
+
runId,
|
|
1039
|
+
stepId: stepState.stepId,
|
|
1040
|
+
invocationId: deriveInvocationId(runId, stepName),
|
|
1041
|
+
attemptCount: stepState.attemptCount,
|
|
1042
|
+
fromInvocationId: stepState.fromStepName
|
|
1043
|
+
? deriveInvocationId(runId, stepState.fromStepName)
|
|
1044
|
+
: undefined,
|
|
1045
|
+
},
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Inline (straight-through) step execution with an in-process retry loop —
|
|
1050
|
+
* shared by inline RPC steps and inline function steps. Same scaffolding
|
|
1051
|
+
* (running → result, or fail → retry-attempt → backoff → retry) wrapped
|
|
1052
|
+
* around a step-specific `doWork` body. Stays O(K): no suspend/replay.
|
|
1053
|
+
*
|
|
1054
|
+
* `onError` is an optional hook for terminal errors that must NOT retry
|
|
1055
|
+
* (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
|
|
1056
|
+
* loop exits immediately without recording a step error or retrying.
|
|
1057
|
+
*/
|
|
1058
|
+
async runInlineRetryLoop(stepState, retries, retryDelay, doWork, onError) {
|
|
1059
|
+
let currentStepState = stepState;
|
|
1060
|
+
while (true) {
|
|
1061
|
+
try {
|
|
1062
|
+
await this.setStepRunning(currentStepState.stepId);
|
|
1063
|
+
const result = await doWork(currentStepState);
|
|
1064
|
+
await this.setStepResult(currentStepState.stepId, result);
|
|
1065
|
+
return result;
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
if (onError)
|
|
1069
|
+
await onError(error);
|
|
1070
|
+
// Record the error (marks step as failed)
|
|
1071
|
+
await this.setStepError(currentStepState.stepId, error);
|
|
1072
|
+
if (currentStepState.attemptCount < retries) {
|
|
1073
|
+
// Create a new pending retry attempt, then back off if configured.
|
|
1074
|
+
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1075
|
+
if (retryDelay) {
|
|
1076
|
+
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1077
|
+
}
|
|
1078
|
+
// Continue loop to retry
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
// No more retries, fail the workflow
|
|
1082
|
+
throw error;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
1088
|
+
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1089
|
+
const fromStepName = this.lastStepName(runId);
|
|
1090
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1091
|
+
// Resolve the retry policy ONCE here so the value persisted on the step
|
|
1092
|
+
// (which drives `retriesExhausted`) is the same one the queue dispatch turns
|
|
1093
|
+
// into `attempts`. Without this the queue could retry N times while the
|
|
1094
|
+
// engine thinks retries are already exhausted (or vice-versa).
|
|
1095
|
+
const resolvedStepOptions = {
|
|
1096
|
+
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1097
|
+
retryDelay: stepOptions?.retryDelay,
|
|
1098
|
+
};
|
|
906
1099
|
// Check if step already exists
|
|
907
1100
|
let stepState;
|
|
908
1101
|
try {
|
|
@@ -910,7 +1103,7 @@ export class PikkuWorkflowService {
|
|
|
910
1103
|
}
|
|
911
1104
|
catch {
|
|
912
1105
|
// Step doesn't exist - create it
|
|
913
|
-
stepState = await this.insertStepState(runId, stepName, rpcName, data,
|
|
1106
|
+
stepState = await this.insertStepState(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
914
1107
|
}
|
|
915
1108
|
if (stepState.status === 'succeeded') {
|
|
916
1109
|
// Return cached result
|
|
@@ -930,94 +1123,66 @@ export class PikkuWorkflowService {
|
|
|
930
1123
|
// Step is already scheduled, pause workflow
|
|
931
1124
|
throw new WorkflowAsyncException(runId, stepName);
|
|
932
1125
|
}
|
|
933
|
-
// Step is pending - schedule it
|
|
934
|
-
await this.setStepScheduled(stepState.stepId);
|
|
935
1126
|
// Hand off to subclass-overridable transport. Default behavior enqueues
|
|
936
1127
|
// via the queue service; DO-style subclasses RPC to a step worker.
|
|
937
|
-
|
|
1128
|
+
// Dispatch BEFORE marking the step `scheduled`: if the queue is down,
|
|
1129
|
+
// dispatchStep throws WorkflowDispatchException and the step stays `pending`,
|
|
1130
|
+
// so the orchestrator's next replay re-dispatches it. Marking `scheduled`
|
|
1131
|
+
// first would strand the step (replay sees `scheduled`, pauses, never
|
|
1132
|
+
// re-enqueues the job that was never created).
|
|
1133
|
+
const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, resolvedStepOptions, fromStepName);
|
|
938
1134
|
if (dispatched) {
|
|
1135
|
+
await this.setStepScheduled(stepState.stepId);
|
|
939
1136
|
throw new WorkflowAsyncException(runId, stepName);
|
|
940
1137
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
while (true) {
|
|
963
|
-
const childRun = await this.getRun(childRunId);
|
|
964
|
-
if (!childRun) {
|
|
965
|
-
throw new WorkflowRunNotFoundError(childRunId);
|
|
966
|
-
}
|
|
967
|
-
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
968
|
-
if (childRun.status === 'failed') {
|
|
969
|
-
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
970
|
-
}
|
|
971
|
-
if (childRun.status === 'cancelled') {
|
|
972
|
-
throw new Error('Sub-workflow was cancelled');
|
|
973
|
-
}
|
|
974
|
-
result = childRun.output;
|
|
975
|
-
break;
|
|
976
|
-
}
|
|
977
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
else {
|
|
981
|
-
result = await rpcService.rpcWithWire(rpcName, data, {
|
|
982
|
-
workflowStep: {
|
|
983
|
-
runId,
|
|
984
|
-
stepId: currentStepState.stepId,
|
|
985
|
-
attemptCount: currentStepState.attemptCount,
|
|
986
|
-
},
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
await this.setStepResult(currentStepState.stepId, result);
|
|
990
|
-
return result;
|
|
991
|
-
}
|
|
992
|
-
catch (error) {
|
|
993
|
-
if (error instanceof RPCNotFoundError) {
|
|
994
|
-
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
995
|
-
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
996
|
-
code: 'RPC_NOT_FOUND',
|
|
997
|
-
});
|
|
998
|
-
throw error;
|
|
1138
|
+
// Inline (no transport available) - execute locally with the shared retry
|
|
1139
|
+
// loop. The body resolves to a sub-workflow result or a plain RPC result.
|
|
1140
|
+
const retries = resolvedStepOptions.retries ?? this.getConfig().retries;
|
|
1141
|
+
const retryDelay = resolvedStepOptions.retryDelay;
|
|
1142
|
+
return this.runInlineRetryLoop(stepState, retries, retryDelay, async (currentStepState) => {
|
|
1143
|
+
// Check if the name refers to a workflow
|
|
1144
|
+
const workflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
1145
|
+
if (workflowMeta) {
|
|
1146
|
+
const childWire = {
|
|
1147
|
+
type: 'workflow',
|
|
1148
|
+
id: rpcName,
|
|
1149
|
+
parentRunId: runId,
|
|
1150
|
+
pikkuUserId: rpcService.wire?.pikkuUserId,
|
|
1151
|
+
};
|
|
1152
|
+
const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: true });
|
|
1153
|
+
await this.setStepChildRunId(currentStepState.stepId, childRunId);
|
|
1154
|
+
// Poll until child workflow completes
|
|
1155
|
+
while (true) {
|
|
1156
|
+
const childRun = await this.getRun(childRunId);
|
|
1157
|
+
if (!childRun) {
|
|
1158
|
+
throw new WorkflowRunNotFoundError(childRunId);
|
|
999
1159
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
if (currentStepState.attemptCount < retries) {
|
|
1004
|
-
// Create a new pending retry attempt
|
|
1005
|
-
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1006
|
-
// Wait for retry delay if specified
|
|
1007
|
-
if (retryDelay) {
|
|
1008
|
-
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1160
|
+
if (WORKFLOW_END_STATES.has(childRun.status)) {
|
|
1161
|
+
if (childRun.status === 'failed') {
|
|
1162
|
+
throw new Error(childRun.error?.message || 'Sub-workflow failed');
|
|
1009
1163
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
throw error;
|
|
1164
|
+
if (childRun.status === 'cancelled') {
|
|
1165
|
+
throw new Error('Sub-workflow was cancelled');
|
|
1166
|
+
}
|
|
1167
|
+
return childRun.output;
|
|
1015
1168
|
}
|
|
1169
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1016
1170
|
}
|
|
1017
1171
|
}
|
|
1018
|
-
|
|
1172
|
+
return this.invokeStepRpc(runId, stepName, currentStepState, rpcName, data, rpcService);
|
|
1173
|
+
}, async (error) => {
|
|
1174
|
+
if (error instanceof RPCNotFoundError) {
|
|
1175
|
+
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
1176
|
+
message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
|
|
1177
|
+
code: 'RPC_NOT_FOUND',
|
|
1178
|
+
});
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1019
1182
|
}
|
|
1020
|
-
async inlineStep(runId,
|
|
1183
|
+
async inlineStep(runId, logicalStepName, fn, stepOptions) {
|
|
1184
|
+
const fromStepName = this.lastStepName(runId);
|
|
1185
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1021
1186
|
// Check if step already exists
|
|
1022
1187
|
let stepState;
|
|
1023
1188
|
try {
|
|
@@ -1025,7 +1190,7 @@ export class PikkuWorkflowService {
|
|
|
1025
1190
|
}
|
|
1026
1191
|
catch {
|
|
1027
1192
|
// Step doesn't exist - create it (inline, no RPC)
|
|
1028
|
-
stepState = await this.insertStepState(runId, stepName, null, null, stepOptions);
|
|
1193
|
+
stepState = await this.insertStepState(runId, stepName, null, null, stepOptions, fromStepName);
|
|
1029
1194
|
}
|
|
1030
1195
|
if (stepState.status === 'succeeded') {
|
|
1031
1196
|
// Return cached result
|
|
@@ -1034,39 +1199,14 @@ export class PikkuWorkflowService {
|
|
|
1034
1199
|
// Execute inline function
|
|
1035
1200
|
const retries = stepOptions?.retries ?? this.getConfig().retries;
|
|
1036
1201
|
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay;
|
|
1037
|
-
let currentStepState = stepState;
|
|
1038
1202
|
// Check if we're running inline (in-memory) or remote (queue-based)
|
|
1039
1203
|
if (this.isInline(runId)) {
|
|
1040
|
-
// Inline mode - execute with retry loop
|
|
1041
|
-
|
|
1042
|
-
try {
|
|
1043
|
-
await this.setStepRunning(currentStepState.stepId);
|
|
1044
|
-
const result = await fn();
|
|
1045
|
-
await this.setStepResult(currentStepState.stepId, result);
|
|
1046
|
-
return result;
|
|
1047
|
-
}
|
|
1048
|
-
catch (error) {
|
|
1049
|
-
// Record the error (marks step as failed)
|
|
1050
|
-
await this.setStepError(currentStepState.stepId, error);
|
|
1051
|
-
// Check if we should retry
|
|
1052
|
-
if (currentStepState.attemptCount < retries) {
|
|
1053
|
-
// Create a new pending retry attempt
|
|
1054
|
-
currentStepState = await this.createRetryAttempt(currentStepState.stepId, 'pending');
|
|
1055
|
-
// Wait for retry delay if specified
|
|
1056
|
-
if (retryDelay) {
|
|
1057
|
-
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(retryDelay)));
|
|
1058
|
-
}
|
|
1059
|
-
// Continue loop to retry
|
|
1060
|
-
}
|
|
1061
|
-
else {
|
|
1062
|
-
// No more retries, fail the workflow
|
|
1063
|
-
throw error;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1204
|
+
// Inline mode - execute with the shared in-process retry loop.
|
|
1205
|
+
return this.runInlineRetryLoop(stepState, retries, retryDelay, () => fn());
|
|
1067
1206
|
}
|
|
1068
1207
|
else {
|
|
1069
|
-
// Remote mode -
|
|
1208
|
+
// Remote mode - single attempt, then suspend for orchestrator-driven retry.
|
|
1209
|
+
let currentStepState = stepState;
|
|
1070
1210
|
try {
|
|
1071
1211
|
await this.setStepRunning(currentStepState.stepId);
|
|
1072
1212
|
const result = await fn();
|
|
@@ -1090,7 +1230,9 @@ export class PikkuWorkflowService {
|
|
|
1090
1230
|
}
|
|
1091
1231
|
}
|
|
1092
1232
|
}
|
|
1093
|
-
async sleepStep(runId,
|
|
1233
|
+
async sleepStep(runId, logicalStepName, duration) {
|
|
1234
|
+
const fromStepName = this.lastStepName(runId);
|
|
1235
|
+
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1094
1236
|
// Check if step already exists
|
|
1095
1237
|
let stepState;
|
|
1096
1238
|
try {
|
|
@@ -1098,9 +1240,7 @@ export class PikkuWorkflowService {
|
|
|
1098
1240
|
}
|
|
1099
1241
|
catch {
|
|
1100
1242
|
// Step doesn't exist - create it (sleep step, no RPC)
|
|
1101
|
-
stepState = await this.insertStepState(runId, stepName, null, {
|
|
1102
|
-
duration,
|
|
1103
|
-
});
|
|
1243
|
+
stepState = await this.insertStepState(runId, stepName, null, { duration }, undefined, fromStepName);
|
|
1104
1244
|
}
|
|
1105
1245
|
if (stepState.status === 'succeeded') {
|
|
1106
1246
|
// Sleep already completed, return immediately
|
|
@@ -1110,13 +1250,20 @@ export class PikkuWorkflowService {
|
|
|
1110
1250
|
// Sleep is already scheduled, pause workflow
|
|
1111
1251
|
throw new WorkflowAsyncException(runId, stepName);
|
|
1112
1252
|
}
|
|
1113
|
-
// Step is pending - schedule it
|
|
1114
|
-
await this.setStepScheduled(stepState.stepId);
|
|
1115
1253
|
// Hand off to subclass-overridable transport. Default behavior schedules
|
|
1116
1254
|
// a delayed sleeper RPC via the scheduler service; DO-style subclasses
|
|
1117
|
-
// override to use native timer primitives (e.g. setAlarm).
|
|
1118
|
-
|
|
1255
|
+
// override to use native timer primitives (e.g. setAlarm). Schedule BEFORE
|
|
1256
|
+
// marking `scheduled` so a scheduler outage leaves the step `pending` for
|
|
1257
|
+
// re-scheduling on replay instead of stranding it (see rpcStep).
|
|
1258
|
+
let scheduled;
|
|
1259
|
+
try {
|
|
1260
|
+
scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
|
|
1261
|
+
}
|
|
1262
|
+
catch (cause) {
|
|
1263
|
+
throw new WorkflowDispatchException(runId, stepName, { cause });
|
|
1264
|
+
}
|
|
1119
1265
|
if (scheduled) {
|
|
1266
|
+
await this.setStepScheduled(stepState.stepId);
|
|
1120
1267
|
throw new WorkflowAsyncException(runId, stepName);
|
|
1121
1268
|
}
|
|
1122
1269
|
// Inline mode - use setTimeout with actual duration
|
|
@@ -1138,7 +1285,8 @@ export class PikkuWorkflowService {
|
|
|
1138
1285
|
return `__workflow_suspend:${reason}`;
|
|
1139
1286
|
}
|
|
1140
1287
|
async suspendStep(runId, reason) {
|
|
1141
|
-
const
|
|
1288
|
+
const fromStepName = this.lastStepName(runId);
|
|
1289
|
+
const suspendStepName = this.nextStepKey(runId, this.getSuspendStepName(reason));
|
|
1142
1290
|
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1143
1291
|
let stepState;
|
|
1144
1292
|
try {
|
|
@@ -1147,12 +1295,12 @@ export class PikkuWorkflowService {
|
|
|
1147
1295
|
catch {
|
|
1148
1296
|
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1149
1297
|
reason,
|
|
1150
|
-
});
|
|
1298
|
+
}, undefined, fromStepName);
|
|
1151
1299
|
}
|
|
1152
1300
|
if (!stepState.stepId) {
|
|
1153
1301
|
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1154
1302
|
reason,
|
|
1155
|
-
});
|
|
1303
|
+
}, undefined, fromStepName);
|
|
1156
1304
|
}
|
|
1157
1305
|
if (stepState.status === 'succeeded') {
|
|
1158
1306
|
return;
|
|
@@ -1206,7 +1354,7 @@ export class PikkuWorkflowService {
|
|
|
1206
1354
|
const singletonServices = getSingletonServices();
|
|
1207
1355
|
const workflow = singletonServices.config?.workflow;
|
|
1208
1356
|
return {
|
|
1209
|
-
retries: workflow?.retries ??
|
|
1357
|
+
retries: workflow?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1210
1358
|
retryDelay: workflow?.retryDelay ?? 0,
|
|
1211
1359
|
orchestratorQueueName: workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|
|
1212
1360
|
stepWorkerQueueName: workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
|