@pikku/core 0.12.42 → 0.12.45

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.
@@ -1464,154 +1464,155 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1464
1464
  data: any,
1465
1465
  rpcService: any
1466
1466
  ): Promise<void> {
1467
- // Use step-level lock to prevent concurrent execution of same step
1468
- await this.withStepLock(runId, stepName, async () => {
1469
- // Get step state
1470
- let stepState = await this.getStepState(runId, stepName)
1471
-
1472
- // Idempotency - if already succeeded, nothing to do
1473
- if (stepState.status === 'succeeded') {
1474
- return
1475
- }
1476
-
1477
- // Log warning if already running (race condition)
1478
- if (stepState.status === 'running') {
1479
- return
1467
+ // Claim the step under the lock ONLY (atomic check-and-mark-running). Do NOT
1468
+ // hold the advisory lock and its pooled connection — across execution: once
1469
+ // a step is 'running' the guard below makes any concurrent worker return
1470
+ // early, so the work + result persistence run with the lock released. Holding
1471
+ // the lock across executeGraphStep (network I/O + more pool queries) let
1472
+ // concurrent steps exhaust the connection pool and self-deadlock.
1473
+ const claimed = await this.withStepLock(runId, stepName, async () => {
1474
+ const stepState = await this.getStepState(runId, stepName)
1475
+ // Already succeeded, or already claimed by another worker — nothing to do.
1476
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
1477
+ return null
1480
1478
  }
1481
-
1482
- // If status is 'failed', this is a retry - create new attempt history
1479
+ // A 'failed' status means this is a retry — start a fresh 'running' attempt.
1483
1480
  if (stepState.status === 'failed') {
1484
- stepState = await this.createRetryAttempt(stepState.stepId, 'running')
1481
+ return this.createRetryAttempt(stepState.stepId, 'running')
1485
1482
  }
1486
-
1487
1483
  if (stepState.status === 'pending' || stepState.status === 'scheduled') {
1488
1484
  await this.setStepRunning(stepState.stepId)
1489
1485
  }
1486
+ return stepState
1487
+ })
1490
1488
 
1491
- try {
1492
- let result: any
1489
+ // Nothing to execute: already succeeded, or another worker owns this step.
1490
+ if (!claimed) {
1491
+ return
1492
+ }
1493
+ const stepState = claimed
1493
1494
 
1494
- const run = await this.getRun(runId)
1495
- if (!run) {
1496
- throw new Error(`Workflow run not found: ${runId}`)
1497
- }
1495
+ try {
1496
+ let result: any
1498
1497
 
1499
- const meta = pikkuState(null, 'workflows', 'meta')
1500
- const workflowMeta = meta[run.workflow]
1501
-
1502
- const isGraphWorkflow =
1503
- workflowMeta?.source === 'graph' ||
1504
- workflowMeta?.source === 'dynamic-workflow'
1505
- // Map the physical step key back to its logical node: a revisit instance
1506
- // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1507
- let graphNodeId: string | undefined
1508
- if (isGraphWorkflow && workflowMeta?.nodes) {
1509
- if (stepName in workflowMeta.nodes) {
1510
- graphNodeId = stepName
1511
- } else {
1512
- const hash = stepName.lastIndexOf('#')
1513
- const base = hash > 0 ? stepName.slice(0, hash) : undefined
1514
- if (base && base in workflowMeta.nodes) graphNodeId = base
1515
- }
1498
+ const run = await this.getRun(runId)
1499
+ if (!run) {
1500
+ throw new Error(`Workflow run not found: ${runId}`)
1501
+ }
1502
+
1503
+ const meta = pikkuState(null, 'workflows', 'meta')
1504
+ const workflowMeta = meta[run.workflow]
1505
+
1506
+ const isGraphWorkflow =
1507
+ workflowMeta?.source === 'graph' ||
1508
+ workflowMeta?.source === 'dynamic-workflow'
1509
+ // Map the physical step key back to its logical node: a revisit instance
1510
+ // is `node#N` (ordinal), which isn't a literal key in `nodes`.
1511
+ let graphNodeId: string | undefined
1512
+ if (isGraphWorkflow && workflowMeta?.nodes) {
1513
+ if (stepName in workflowMeta.nodes) {
1514
+ graphNodeId = stepName
1515
+ } else {
1516
+ const hash = stepName.lastIndexOf('#')
1517
+ const base = hash > 0 ? stepName.slice(0, hash) : undefined
1518
+ if (base && base in workflowMeta.nodes) graphNodeId = base
1516
1519
  }
1517
- if (graphNodeId) {
1518
- result = await executeGraphStep(
1519
- this,
1520
- rpcService,
1521
- runId,
1522
- stepState.stepId,
1523
- graphNodeId,
1520
+ }
1521
+ if (graphNodeId) {
1522
+ result = await executeGraphStep(
1523
+ this,
1524
+ rpcService,
1525
+ runId,
1526
+ stepState.stepId,
1527
+ graphNodeId,
1528
+ rpcName,
1529
+ data,
1530
+ run.workflow
1531
+ )
1532
+ } else {
1533
+ // Check if rpcName refers to a sub-workflow
1534
+ const subWorkflowMeta = meta[rpcName]
1535
+ if (subWorkflowMeta) {
1536
+ const childWire: WorkflowRunWire = {
1537
+ type: 'workflow',
1538
+ id: rpcName,
1539
+ parentRunId: runId,
1540
+ parentStepId: stepState.stepId,
1541
+ pikkuUserId: rpcService.wire?.pikkuUserId,
1542
+ }
1543
+ const shouldInline = !getSingletonServices()?.queueService
1544
+ const { runId: childRunId } = await this.startWorkflow(
1524
1545
  rpcName,
1525
1546
  data,
1526
- run.workflow
1547
+ childWire,
1548
+ rpcService,
1549
+ { inline: shouldInline }
1527
1550
  )
1528
- } else {
1529
- // Check if rpcName refers to a sub-workflow
1530
- const subWorkflowMeta = meta[rpcName]
1531
- if (subWorkflowMeta) {
1532
- const childWire: WorkflowRunWire = {
1533
- type: 'workflow',
1534
- id: rpcName,
1535
- parentRunId: runId,
1536
- parentStepId: stepState.stepId,
1537
- pikkuUserId: rpcService.wire?.pikkuUserId,
1551
+ await this.setStepChildRunId(stepState.stepId, childRunId)
1552
+ if (shouldInline) {
1553
+ const childRun = await this.getRun(childRunId)
1554
+ if (childRun?.status === 'failed') {
1555
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
1538
1556
  }
1539
- const shouldInline = !getSingletonServices()?.queueService
1540
- const { runId: childRunId } = await this.startWorkflow(
1541
- rpcName,
1542
- data,
1543
- childWire,
1544
- rpcService,
1545
- { inline: shouldInline }
1546
- )
1547
- await this.setStepChildRunId(stepState.stepId, childRunId)
1548
- if (shouldInline) {
1549
- const childRun = await this.getRun(childRunId)
1550
- if (childRun?.status === 'failed') {
1551
- throw new Error(
1552
- childRun.error?.message || 'Sub-workflow failed'
1553
- )
1554
- }
1555
- if (childRun?.status === 'cancelled') {
1556
- throw new Error('Sub-workflow was cancelled')
1557
- }
1558
- result = childRun?.output
1559
- } else {
1560
- throw new ChildWorkflowStartedException(
1561
- runId,
1562
- stepState.stepId,
1563
- childRunId
1564
- )
1557
+ if (childRun?.status === 'cancelled') {
1558
+ throw new Error('Sub-workflow was cancelled')
1565
1559
  }
1560
+ result = childRun?.output
1566
1561
  } else {
1567
- result = await this.invokeStepRpc(
1562
+ throw new ChildWorkflowStartedException(
1568
1563
  runId,
1569
- stepName,
1570
- stepState,
1571
- rpcName,
1572
- data,
1573
- rpcService
1564
+ stepState.stepId,
1565
+ childRunId
1574
1566
  )
1575
1567
  }
1576
- }
1577
-
1578
- // Store result and mark succeeded
1579
- await this.setStepResult(stepState.stepId, result)
1580
-
1581
- // Resume orchestrator to continue workflow
1582
- await this.resumeWorkflow(runId)
1583
- } catch (error: any) {
1584
- if (error instanceof ChildWorkflowStartedException) {
1585
- this.logger?.debug(
1586
- `Workflow step '${stepName}': child workflow ${error.childRunId} started, waiting for completion`
1568
+ } else {
1569
+ result = await this.invokeStepRpc(
1570
+ runId,
1571
+ stepName,
1572
+ stepState,
1573
+ rpcName,
1574
+ data,
1575
+ rpcService
1587
1576
  )
1588
- return
1589
1577
  }
1578
+ }
1590
1579
 
1591
- if (error instanceof RPCNotFoundError) {
1592
- await this.setStepError(stepState.stepId, error)
1593
- await this.updateRunStatus(runId, 'suspended', undefined, {
1594
- message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1595
- code: 'RPC_NOT_FOUND',
1596
- })
1597
- return
1598
- }
1580
+ // Store result and mark succeeded
1581
+ await this.setStepResult(stepState.stepId, result)
1582
+
1583
+ // Resume orchestrator to continue workflow
1584
+ await this.resumeWorkflow(runId)
1585
+ } catch (error: any) {
1586
+ if (error instanceof ChildWorkflowStartedException) {
1587
+ this.logger?.debug(
1588
+ `Workflow step '${stepName}': child workflow ${error.childRunId} started, waiting for completion`
1589
+ )
1590
+ return
1591
+ }
1599
1592
 
1600
- // Store error and mark failed
1593
+ if (error instanceof RPCNotFoundError) {
1601
1594
  await this.setStepError(stepState.stepId, error)
1595
+ await this.updateRunStatus(runId, 'suspended', undefined, {
1596
+ message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
1597
+ code: 'RPC_NOT_FOUND',
1598
+ })
1599
+ return
1600
+ }
1602
1601
 
1603
- const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
1604
- const retriesExhausted = stepState.attemptCount >= maxAttempts
1602
+ // Store error and mark failed
1603
+ await this.setStepError(stepState.stepId, error)
1605
1604
 
1606
- if (retriesExhausted) {
1607
- // No more retries - resume orchestrator to mark workflow as failed
1608
- await this.resumeWorkflow(runId)
1609
- }
1605
+ const maxAttempts = (stepState.retries ?? DEFAULT_STEP_RETRIES) + 1
1606
+ const retriesExhausted = stepState.attemptCount >= maxAttempts
1610
1607
 
1611
- // Always throw so queue knows the job failed and can retry if needed
1612
- throw error
1608
+ if (retriesExhausted) {
1609
+ // No more retries - resume orchestrator to mark workflow as failed
1610
+ await this.resumeWorkflow(runId)
1613
1611
  }
1614
- })
1612
+
1613
+ // Always throw so queue knows the job failed and can retry if needed
1614
+ throw error
1615
+ }
1615
1616
  }
1616
1617
 
1617
1618
  /**