KBkilterKB
dev

Durable Workflows (Temporal)

Wiring Temporal is not a single client init — it's a small architecture with two moving parts that share one task queue name:

app (client)  ──start──▶  Temporal server  ──dispatch──▶  worker process
                          (task queue "X")               (runs workflows + activities)
  • The client lives in the app (route handlers, job triggers). It starts and signals workflows. It never runs workflow code.
  • The worker is a separate long-running process. It registers your workflows and activities and polls the task queue.

kilter provisions the server and provides two env vars — confirm with kilter env, never hardcode:

  • TEMPORAL_ADDRESS — gRPC frontend (:7233); client and worker both connect here.
  • TEMPORAL_UI_URL — the Web UI (:8233) for inspecting executions.

Setup

npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity

Client — enqueue from the app:

const connection = await Connection.connect({ address: process.env.TEMPORAL_ADDRESS });
const client = new Client({ connection });
await client.workflow.start(orderWorkflow, {
  taskQueue: 'orders',            // MUST match the worker exactly
  workflowId: `order-${orderId}`, // dedup key — reused id = same workflow
  args: [orderId],
});

Worker — its own entry point:

const worker = await Worker.create({
  workflowsPath: require.resolve('./workflows'),
  activities,
  taskQueue: 'orders',            // MUST match the client
});
await worker.run();

Go (go.temporal.io/sdk) and Python (temporalio) use the same client/worker/task-queue model.

The determinism rule

Temporal replays workflow code on every state transition, so workflows must be pure orchestration: no Date.now(), Math.random(), fetch, DB calls, or reading env at runtime. Use workflow APIs instead (sleep, condition, proxyActivities). All side effects — DB writes, HTTP calls, email — go in activities, which are retried independently and never replayed.

// workflows.ts — deterministic orchestration only
const { chargeCard, sendReceipt } = proxyActivities<typeof activities>({
  startToCloseTimeout: '1 minute',
});
export async function orderWorkflow(orderId: string) {
  await chargeCard(orderId);   // side effect → activity
  await sleep('1 day');        // durable timer — survives restarts
  await sendReceipt(orderId);
}
The mistake every first integration makes

Putting a fetch or DB call directly in a workflow. It appears to work — until a replay produces a DeterminismViolation. If you see that error, move the side effect into an activity; for incompatible code changes to running workflows, version with patched().

Deploying the worker

Declare it under processes: in kilter.yaml. Worker processes share the app image and inherit its full env bag, so TEMPORAL_ADDRESS is already present:

processes:
  worker:
    command: [node, dist/temporal/worker.js]

kilter up starts it alongside the app; kilter deploy ships it as its own workload.

Verify

  1. kilter up (app + worker), trigger a workflow through the client path.
  2. Open TEMPORAL_UI_URL — the execution should appear and complete.
SymptomCause / fix
Workflow stuck in RunningNo worker polling that queue — queue-name mismatch, or worker not running (kilter status)
Worker starts, does nothingWorkflows/activities not registered, or wrong TEMPORAL_ADDRESS
Activity retries foreverNo retry cap — set retry.maximumAttempts in proxyActivities
Can't reach the serverHardcoded/stale address — read it from kilter env