name: dbos-patterns
description: DBOS durable execution patterns and CRITICAL constraints for ChainGraph executor. Use when working on workflows, steps, execution, or any DBOS-related code. Contains MUST-FOLLOW constraints about what can be called from workflows vs steps. Triggers: dbos, workflow, step, durable, execution, startWorkflow, writeStream, recv, send, runStep, atomic, checkpoint, WorkflowQueue, queue, cancelWorkflow, Promise.allSettled. (project)
DBOS Patterns for ChainGraph
This skill covers DBOS (Database-Oriented Operating System) patterns used in the ChainGraph executor. CRITICAL: Contains constraints that agents MUST follow to avoid runtime errors.
CRITICAL Constraints
The Most Important Rule
DBOS context methods have strict calling restrictions based on WHERE you are:
async function myWorkflow(task: Task): Promise<Result> {
await DBOS.send(...)
await DBOS.recv(...)
await DBOS.startWorkflow(...)
await DBOS.writeStream(...)
await DBOS.setEvent(...)
await DBOS.sleep(...)
const result = await DBOS.runStep(() => myStep(task))
return result
}
async function myStep(task: Task): Promise<StepResult> {
await DBOS.writeStream(...)
return { data: ... }
}
Constraint Reference Table
| DBOS Method | From Workflow | From Step |
|---|
DBOS.send() | ✅ | ❌ |
DBOS.recv() | ✅ | ❌ |
DBOS.startWorkflow() | ✅ | ❌ |
DBOS.setEvent() / getEvent() | ✅ | ❌ |
DBOS.sleep() | ✅ | ❌ |
DBOS.cancelWorkflow() | ✅ | ❌ |
DBOS.runStep() | ✅ | ❌ |
DBOS.writeStream() | ✅ | ✅ |
DBOS.readStream() | ✅ | ❌ |
Promise Handling
NEVER use Promise.all() - it fails fast and leaves promises unresolved, risking unhandled rejections.
const results = await Promise.all([step1(), step2(), step3()])
const results = await Promise.allSettled([step1(), step2(), step3()])
Memory Isolation
Workflows and steps should NOT have side effects outside their own scope:
- ✅ Can READ global variables
- ❌ Must NOT create or update global variables
- ❌ Must NOT modify shared state outside return values
Queue Initialization Order
CRITICAL: WorkflowQueue MUST be created before DBOS.launch() is called!
export const executionQueue = new WorkflowQueue(QUEUE_NAME, {
workerConcurrency: config.dbos.workerConcurrency ?? 5,
concurrency: config.dbos.queueConcurrency ?? 100,
})
Design Patterns
Pattern 1: Signal Pattern (Race Condition Fix)
Problem: Client subscribes to events before the stream exists.
Solution: Workflow writes initialization event BEFORE waiting for start signal.
File: packages/chaingraph-executor/server/dbos/workflows/ExecutionWorkflows.ts
Timeline:
1. create execution (tRPC)
└─ Workflow starts → writes EXECUTION_CREATED → stream exists! ✅
└─ Workflow waits for START_SIGNAL... ⏸️
2. subscribe events (tRPC)
└─ Stream already exists → immediately receives EXECUTION_CREATED ✅
3. start execution (tRPC)
└─ Sends START_SIGNAL → workflow continues ▶️
Implementation Pattern:
async function executionWorkflow(task: ExecutionTask): Promise<ExecutionResult> {
await DBOS.writeStream('events', {
executionId: task.executionId,
event: 'EXECUTION_CREATED',
timestamp: Date.now(),
})
const signal = await DBOS.recv<string>('START_SIGNAL', 300)
if (!signal) {
throw new Error('Execution start timeout')
}
}
Pattern 2: Shared State Pattern (Command System)
Problem: Cannot call DBOS.recv() from steps, but need to check for commands.
Solution: Workflow polls messages, updates shared state object that step reads.
Files:
- Workflow:
server/dbos/workflows/ExecutionWorkflows.ts
- Step:
server/dbos/steps/ExecuteFlowAtomicStep.ts
interface CommandController {
currentCommand: 'PAUSE' | 'RESUME' | 'STEP' | null
}
async function executionWorkflow(task: ExecutionTask) {
const commandController: CommandController = { currentCommand: null }
const abortController = new AbortController()
const pollCommands = async () => {
while (!abortController.signal.aborted) {
const cmd = await DBOS.recv<{ command: string }>('COMMAND', 0.5)
if (cmd) {
if (cmd.command === 'STOP') {
abortController.abort()
} else {
commandController.currentCommand = cmd.command
}
}
}
}
const result = await DBOS.runStep(() =>
executeFlowAtomic(task, abortController, commandController)
)
return result
}
async function executeFlowAtomic(
task: ExecutionTask,
abortController: AbortController,
commandController: CommandController
) {
const checkCommands = setInterval(() => {
if (commandController.currentCommand === 'PAUSE') {
debugger.pause()
} else if (commandController.currentCommand === 'RESUME') {
debugger.continue()
}
commandController.currentCommand = null
}, 100)
}
Pattern 3: Collect & Spawn Pattern (Child Executions)
Problem: Cannot call DBOS.startWorkflow() from steps, but Event Emitter nodes need to spawn children.
Solution: Step collects child tasks and returns them, workflow spawns them.
Files:
- Step:
server/dbos/steps/ExecuteFlowAtomicStep.ts:346-401
- Workflow:
server/dbos/workflows/ExecutionWorkflows.ts
async function executeFlowAtomic(task: ExecutionTask): Promise<ExecutionResult> {
const collectedChildTasks: ExecutionTask[] = []
await engine.execute()
for (const event of context.emittedEvents.filter(e => !e.processed)) {
event.processed = true
const childTask = await createChildTask(instance, event, store)
collectedChildTasks.push(childTask)
}
return {
status: 'completed',
childTasks: collectedChildTasks,
}
}
async function executionWorkflow(task: ExecutionTask) {
const result = await DBOS.runStep(() => executeFlowAtomic(task))
if (result.childTasks?.length > 0) {
for (const childTask of result.childTasks) {
await DBOS.startWorkflow(executionWorkflow, {
workflowID: childTask.executionId
})(childTask)
}
}
return result
}
Pattern 4: Auto-Start Pattern (Child Execution Lifecycle)
Problem: Children need manual start call, slowing down execution tree.
Solution: Children skip the signal wait entirely and start immediately.
File: server/dbos/workflows/ExecutionWorkflows.ts:192-214
async function executionWorkflow(task: ExecutionTask) {
const executionRow = await store.get(task.executionId)
const isChildExecution = !!executionRow.parentExecutionId
await DBOS.writeStream('events', { event: 'EXECUTION_CREATED', ... })
if (!isChildExecution) {
const startSignal = await DBOS.recv<string>('START_SIGNAL', 300)
if (!startSignal) {
throw new Error('Execution start timeout')
}
} else {
DBOS.logger.info(`Child execution auto-start, beginning execution`)
}
}
Child Execution Lifecycle:
Parent spawns child via DBOS.startWorkflow()
└─ Child workflow starts
├─ Writes EXECUTION_CREATED event
├─ Detects parentExecutionId
├─ Skips signal wait (auto-start)
└─ Executes flow immediately
Pattern 5: WorkflowQueue Pattern (Managed Concurrency)
Problem: Need to manage concurrency and ensure idempotent workflow spawning.
Solution: Use WorkflowQueue with concurrency limits and deduplication.
File: server/dbos/queue.ts
import { WorkflowQueue } from '@dbos-inc/dbos-sdk'
export const executionQueue = new WorkflowQueue('chaingraph-executions', {
workerConcurrency: 5,
concurrency: 100,
})
await DBOS.startWorkflow(ExecutionWorkflows, {
queueName: executionQueue.name,
workflowID: childTask.executionId,
enqueueOptions: {
deduplicationID: childTask.executionId,
},
}).executeChainGraph(childTask)
Pattern 6: Parent Monitoring Pattern (Child Stops if Parent Dies)
Problem: Child executions should stop if their parent completes or fails.
Solution: Background checker monitors parent workflow status.
File: server/dbos/workflows/ExecutionWorkflows.ts
async function monitorParentWorkflow(
parentExecutionId: string,
abortController: AbortController
) {
while (!abortController.signal.aborted) {
const parentStatus = await DBOS.getWorkflowStatus(parentExecutionId)
if (parentStatus?.status === 'COMPLETED' ||
parentStatus?.status === 'ERROR' ||
parentStatus?.status === 'CANCELLED') {
abortController.abort('Parent workflow has ended')
break
}
await DBOS.sleep(5)
}
}
Three-Phase Workflow Structure
ChainGraph executions follow a three-phase structure:
┌──────────────────────────────────────────────────────────────┐
│ PHASE 1: Stream Initialization (Lines 148-214) │
│ ├─ Create CommandController │
│ ├─ Write EXECUTION_CREATED event (stream exists!) │
│ ├─ Auto-start children (send START_SIGNAL to self) │
│ └─ Wait for START_SIGNAL │
├──────────────────────────────────────────────────────────────┤
│ PHASE 2: Execution (Lines 216-374) │
│ ├─ Step 1: updateToRunning() │
│ ├─ Step 2: executeFlowAtomic() ← Core execution │
│ └─ Spawn children via DBOS.startWorkflow() │
├──────────────────────────────────────────────────────────────┤
│ PHASE 3: Cleanup (Lines 376-423) │
│ ├─ Step 3: updateToCompleted() │
│ ├─ Stop command polling │
│ └─ DBOS auto-closes event stream │
└──────────────────────────────────────────────────────────────┘
Key Files
| File | Purpose | Critical? |
|---|
server/dbos/workflows/ExecutionWorkflows.ts | Main orchestration workflow | ⭐⭐⭐ |
server/dbos/steps/ExecuteFlowAtomicStep.ts | Core execution step | ⭐⭐⭐ |
server/dbos/queue.ts:17-35 | Queue initialization (MUST be before DBOS.launch) | ⭐⭐⭐ |
server/dbos/config.ts | DBOS initialization | ⭐⭐ |
server/dbos/DBOSExecutionWorker.ts | Worker lifecycle | ⭐⭐ |
server/dbos/steps/UpdateStatusStep.ts | Status updates | ⭐ |
server/implementations/dbos/DBOSEventBus.ts | Event streaming via DBOS.writeStream() | ⭐⭐ |
server/utils/config.ts:70-139 | Environment config | ⭐⭐ |
Environment Variables
ENABLE_DBOS_EXECUTION=true
DBOS_ADMIN_ENABLED=true
DBOS_ADMIN_PORT=3022
DBOS_QUEUE_CONCURRENCY=100
DBOS_WORKER_CONCURRENCY=5
DBOS_CONDUCTOR_URL=https://conductor.dbos.dev
DBOS_APPLICATION_NAME=chaingraph-executor
DBOS_CONDUCTOR_KEY=your-api-key-here
Anti-Patterns
Anti-Pattern #1: Calling DBOS methods from steps
async function myStep(data: string) {
await DBOS.send('other-workflow', 'message', 'TOPIC')
}
async function myStep(data: string): Promise<{ toSend: Message }> {
return { toSend: { target: 'other-workflow', message: 'hello' } }
}
async function myWorkflow() {
const result = await DBOS.runStep(() => myStep(data))
await DBOS.send(result.toSend.target, result.toSend.message, 'TOPIC')
}
Anti-Pattern #2: Splitting atomic execution
await DBOS.runStep(() => loadFlow())
await DBOS.runStep(() => executeFlow())
await DBOS.runStep(() => executeFlowAtomic(task))
Anti-Pattern #3: Making children wait for START_SIGNAL
async function executionWorkflow(task: ExecutionTask) {
const isChild = !!executionRow.parentExecutionId
await DBOS.recv('START_SIGNAL', 300)
}
async function executionWorkflow(task: ExecutionTask) {
const isChild = !!executionRow.parentExecutionId
if (!isChild) {
await DBOS.recv('START_SIGNAL', 300)
}
}
Anti-Pattern #4: Using Promise.all() for parallel steps
const results = await Promise.all([
DBOS.runStep(() => step1()),
DBOS.runStep(() => step2()),
DBOS.runStep(() => step3()),
])
const results = await Promise.allSettled([
DBOS.runStep(() => step1()),
DBOS.runStep(() => step2()),
DBOS.runStep(() => step3()),
])
Anti-Pattern #5: Memory side effects in workflows/steps
let globalCounter = 0
async function myWorkflow() {
globalCounter++
}
async function myWorkflow(): Promise<{ count: number }> {
const count = calculateCount()
return { count }
}
Anti-Pattern #6: Creating queue after DBOS.launch()
await DBOS.launch()
const queue = new WorkflowQueue('my-queue')
const queue = new WorkflowQueue('my-queue')
await DBOS.launch()
Quick Reference
| Need | Pattern | Where |
|---|
| Stream exists before subscribe | Signal Pattern | Write event before recv() |
| Commands during step execution | Shared State | Workflow polls, step reads object |
| Spawn child workflows | Collect & Spawn | Step collects, workflow spawns |
| Children start immediately | Auto-Start | Skip signal wait |
| Real-time events from step | DBOS.writeStream() | Only stream method allowed in steps |
| Managed concurrency | WorkflowQueue | Queue with workerConcurrency/concurrency |
| Child stops if parent dies | Parent Monitoring | Background status checker |
| Parallel steps safely | Promise.allSettled() | Never use Promise.all() |
DBOS Workflow Architecture
┌─────────────────────────────────────────────────────────────┐
│ WORKFLOW (can call ALL DBOS methods) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ DBOS.send() │ │ DBOS.recv() │ │startWorkflow│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DBOS.runStep(() => ...) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ STEP (ONLY writeStream allowed) │ │ │
│ │ │ │ │ │
│ │ │ ✅ DBOS.writeStream() │ │ │
│ │ │ ❌ DBOS.send/recv/startWorkflow/sleep/... │ │ │
│ │ │ │ │ │
│ │ │ return { childTasks: [...] } │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ // After step completes: │
│ for (child of result.childTasks) { │
│ await DBOS.startWorkflow(...)(child) // ✅ Allowed here│
│ } │
└─────────────────────────────────────────────────────────────┘
Advanced DBOS Features
For advanced DBOS features not currently used in ChainGraph (Debouncer, forkWorkflow, versioning, rate limiting, partitioned queues), see dbos-advanced.md in this skill directory.
Related Skills
executor-architecture - Package overview
chaingraph-concepts - Core domain concepts
subscription-sync - Event streaming patterns
trpc-execution - Execution tRPC procedures
trpc-patterns - General tRPC framework patterns