Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions miscellaneous/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,26 @@
"path": "https://github.com/fission/examples/tree/main/miscellaneous/workshop",
"tag": ["Workshop"],
"language": "Go"
},
{
"name": "Workflow: Order Pipeline",
"description": "E-commerce checkout orchestration: parallel fraud/stock screening, choice routing, payment retry with typed-error catch.",
"path": "https://github.com/fission/examples/tree/main/miscellaneous/workflows/order-pipeline",
"tag": ["Workflows","Orchestration","Javascript"],
"language": "Javascript"
},
{
"name": "Workflow: Batch Lead Enrichment",
"description": "Map fan-out over a CRM lead batch with bounded concurrency; ordered join feeds an aggregation step.",
"path": "https://github.com/fission/examples/tree/main/miscellaneous/workflows/batch-enrichment",
"tag": ["Workflows","Orchestration","Javascript"],
"language": "Javascript"
},
{
"name": "Workflow: Payment Dunning",
"description": "Subscription payment recovery with durable Wait timers between charge attempts.",
"path": "https://github.com/fission/examples/tree/main/miscellaneous/workflows/payment-dunning",
"tag": ["Workflows","Orchestration","Javascript"],
"language": "Javascript"
}
]
75 changes: 75 additions & 0 deletions miscellaneous/workflows/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Fission Workflows

Durable, multi-step orchestration of Fission functions, declared as a `Workflow` custom resource.
A `Workflow` is a state machine: each state invokes a function (or routes, fans out, or waits), and the engine records every step in a durable event log — so a run survives controller restarts, retries transient failures with backoff, and routes business errors along the paths you declare.

These examples are real business processes, each highlighting a different part of the feature:

| Example | Business case | Features shown |
|---|---|---|
| [`order-pipeline/`](order-pipeline/) | E-commerce checkout orchestration | Task chaining, **Parallel** screening, **Choice** routing, **retry** with backoff, **catch** on typed errors, `resultPath` document merging |
| [`batch-enrichment/`](batch-enrichment/) | CRM lead scoring over a batch | **Map** fan-out over an array with `maxConcurrency`, ordered join feeding an aggregation step |
| [`payment-dunning/`](payment-dunning/) | Subscription payment recovery | Durable **Wait** timers, catch-driven business routing, run history |

## Prerequisites

- Fission installed with workflows enabled (they are off by default):

```bash
helm upgrade fission fission-charts/fission-all --namespace fission \
--set workflows.enabled=true \
--set statestore.enabled=true --set statestore.mode=embedded
```

The workflow engine keeps run state in the Fission statestore, so `statestore.enabled` is required.

- A `fission` CLI with the `workflow` command group (`fission workflow --help` works).

- A Node.js environment for the example functions (all examples share it):

```bash
fission environment create --name nodejs --image ghcr.io/fission/node-env-22
```

## The 5-minute tour

```bash
cd order-pipeline

# Functions are plain Fission functions — nothing workflow-specific in them.
for fn in validate-order fraud-score inventory-check charge-card fulfil-order reject-order; do
fission function create --name wf-$fn --env nodejs --code functions/$fn.js
done

# The workflow is one YAML document.
fission workflow create -f workflow.yaml

# Render the state machine as a Mermaid diagram (stdout), or draw it in a
# browser with --open (served locally — the graph never leaves your machine).
fission workflow graph --name order-pipeline
fission workflow graph --name order-pipeline --open

# Start a run and watch it.
fission workflow run --name order-pipeline --input @inputs/happy.json
fission workflow runs list --workflow order-pipeline
fission workflow runs describe --name <run-name>
fission workflow runs history --name <run-name>

# "Where did this run stop?" — the same diagram, with every state colored by
# what THIS run did: succeeded, active, failed, or never reached.
fission workflow runs graph --name <run-name> --open
```

## Concepts you'll see in the YAML

- **Task** — invoke a function; `retry` re-runs transient failures (5xx / network) with exponential backoff, `catch` routes typed business errors to another state. A catch route's `resultPath` merges the error object into the document (so the target keeps the business data); without it the error object *replaces* the document (Step Functions parity).
- **Typed errors** — a function signals a business error by returning non-2xx with `{"errorType": "PaymentDeclined", "cause": {...}}`; catch routes match on `errorType`. 4xx without a type is `Fission.PermanentError` (never retried), 5xx is `Fission.FunctionError` (retryable).
- **Choice** — data-driven routing on the run document, evaluated by the engine (no function call).
- **Parallel / Map** — fan out branches (fixed set / one per array element); the join is an ordered array of branch results.
- **Wait** — a durable timer; the run sleeps in the statestore, not in a pod.
- **`inputPath` / `resultPath` / `outputPath`** — JSONPath shaping of what a state sees and where its output lands, so the run document accumulates results instead of being overwritten.
- **`historyRetention`** — how many finished runs (and how old) to keep before the engine garbage-collects them.

## Idempotency

The engine guarantees each state executes **at most once per attempt**, and passes `X-Fission-Workflow-Run` + `X-Fission-Workflow-Attempt` headers on every invocation — use them as idempotency keys when a step calls an external system (see `charge-card.js`).
47 changes: 47 additions & 0 deletions miscellaneous/workflows/batch-enrichment/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Batch enrichment — Map fan-out

Enrich a batch of CRM leads: a **Map** state invokes the scoring function once per element of `$.leads` — at most 3 concurrently — and the ordered join array feeds a summary step.
The function stays single-record simple; the workflow owns the fan-out, throttling, retries and ordering.

```mermaid
stateDiagram-v2
[*] --> enrich
state enrich {
[*] --> score
score --> [*]
}
enrich --> summarize: ordered join array
summarize --> [*]
```

## Deploy

```bash
fission environment create --name nodejs --image ghcr.io/fission/node-env-22 # once

fission function create --name wf-enrich-lead --env nodejs --code functions/enrich-lead.js
fission function create --name wf-summarize-leads --env nodejs --code functions/summarize-leads.js

fission workflow create -f workflow.yaml
```

## Run

```bash
fission workflow run --name batch-enrichment --input @inputs/leads.json
fission workflow runs describe --name <run-name>
```

The final output is the campaign summary:

```json
{"total": 6, "bySegment": {"hot": 3, "warm": 1, "cold": 2}, "hotLeads": ["cto@acme-robotics.com", ...]}
```

`fission workflow runs history --name <run-name>` shows one `StepScheduled`/`StepSucceeded` pair per lead (branch keys `0`..`5`) and a `BranchesJoined` event carrying the ordered results.

## Why a workflow and not a loop in one function?

- **Durability** — a controller restart mid-batch resumes exactly where it left off; already-enriched leads are not re-processed.
- **Throttling** — `maxConcurrency: 3` bounds pressure on the (real-world) enrichment API without any queueing code.
- **Per-item retries** — a transient failure on one lead retries that lead only, not the whole batch.
26 changes: 26 additions & 0 deletions miscellaneous/workflows/batch-enrichment/functions/enrich-lead.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Enriches ONE lead. The Map state invokes this function once per element
// of $.leads, up to maxConcurrency at a time, so the function only ever
// thinks about a single record — the workflow owns the fan-out, ordering
// and retry story.
module.exports = async function (context) {
const lead = context.request.body || {};

// Firmographic score: bigger companies and work emails score higher.
let score = 0;
const employees = lead.employees || 0;
if (employees >= 1000) score += 50;
else if (employees >= 100) score += 30;
else if (employees >= 10) score += 15;

const email = lead.email || '';
const freeMail = /@(gmail|yahoo|outlook|hotmail)\./.test(email);
if (!freeMail && email.includes('@')) score += 30;
if ((lead.title || '').match(/vp|chief|head|director/i)) score += 20;

const segment = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold';
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...lead, score, segment }),
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Receives the Map join output — the ordered array of enriched leads — and
// produces the campaign summary a marketing tool would consume.
module.exports = async function (context) {
const leads = Array.isArray(context.request.body) ? context.request.body : [];
const bySegment = { hot: 0, warm: 0, cold: 0 };
for (const lead of leads) bySegment[lead.segment] = (bySegment[lead.segment] || 0) + 1;

return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
total: leads.length,
bySegment,
hotLeads: leads.filter((l) => l.segment === 'hot').map((l) => l.email),
}),
};
};
11 changes: 11 additions & 0 deletions miscellaneous/workflows/batch-enrichment/inputs/leads.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"campaign": "q3-webinar",
"leads": [
{ "email": "cto@acme-robotics.com", "company": "Acme Robotics", "employees": 2400, "title": "CTO" },
{ "email": "maya@brightpath.io", "company": "BrightPath", "employees": 140, "title": "Head of Platform" },
{ "email": "jordan.p@gmail.com", "company": "", "employees": 0, "title": "" },
{ "email": "vp.eng@finlever.com", "company": "FinLever", "employees": 800, "title": "VP Engineering" },
{ "email": "sam@tinyshop.dev", "company": "TinyShop", "employees": 6, "title": "Founder" },
{ "email": "casey@yahoo.com", "company": "", "employees": 12, "title": "Analyst" }
]
}
31 changes: 31 additions & 0 deletions miscellaneous/workflows/batch-enrichment/workflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
apiVersion: fission.io/v1
kind: Workflow
metadata:
name: batch-enrichment
spec:
startAt: enrich
timeout: 30m
historyRetention:
maxCount: 20
maxAge: 24h
states:

# Fan out over $.leads: one branch execution per array element, at most
# 3 in flight at a time (be kind to the enrichment API you'd really
# call here). The join output is the ordered array of branch results —
# element i of the output corresponds to element i of the input.
enrich:
type: Map
itemsPath: $.leads
maxConcurrency: 3
branches:
- startAt: score
states:
score: { type: Task, function: { name: wf-enrich-lead }, end: true }
next: summarize

# The join array becomes this state's input document.
summarize:
type: Task
function: { name: wf-summarize-leads }
end: true
59 changes: 59 additions & 0 deletions miscellaneous/workflows/order-pipeline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Order pipeline — checkout orchestration

The flagship workflow example: an e-commerce checkout that validates an order, screens it for fraud and stock **in parallel**, routes on the results, charges the card **with retry and a catch for declines**, and converges every failure onto a single rejection path.

```mermaid
stateDiagram-v2
[*] --> validate
validate --> screening
validate --> reject: InvalidOrder
screening --> decision
decision --> reject: riskScore > 70
decision --> reject: OUT_OF_STOCK
decision --> charge: default
charge --> fulfil
charge --> reject: PaymentDeclined
fulfil --> [*]
reject --> [*]
```

## Deploy

```bash
fission environment create --name nodejs --image ghcr.io/fission/node-env-22 # once

for fn in validate-order fraud-score inventory-check charge-card fulfil-order reject-order; do
fission function create --name wf-$fn --env nodejs --code functions/$fn.js
done

fission workflow create -f workflow.yaml
```

## Run every path

Each sample input deterministically exercises one route:

| Input | What happens |
|---|---|
| `inputs/happy.json` | validate → parallel screening → charge → **FULFILLED** |
| `inputs/flaky-gateway.json` | charge gets a 500 on attempt 1, the **retry policy** re-invokes with backoff, attempt 2 succeeds → FULFILLED (see two `StepFailed`/`StepScheduled` pairs in the history) |
| `inputs/declined-card.json` | charge returns typed `PaymentDeclined` → **catch** routes to reject (no retries burned on a decline) |
| `inputs/high-fraud.json` | fraud branch scores 100 → **Choice** routes to reject |
| `inputs/out-of-stock.json` | inventory branch reports OUT_OF_STOCK → Choice routes to reject |
| `inputs/invalid.json` | validation fails with typed `InvalidOrder` → straight to reject |

```bash
fission workflow run --name order-pipeline --input @inputs/happy.json
# workflow run 'order-pipeline-xxxxx' started

fission workflow runs describe --name order-pipeline-xxxxx
fission workflow runs history --name order-pipeline-xxxxx --io
```

The `--io` flag shows each step's input/output, including the parallel join array landing at `$.screening` and the charge receipt at `$.charge`.

## What to look at

- **`workflow.yaml`** — the whole business process in ~70 commented lines.
- **`functions/charge-card.js`** — how a payment step distinguishes *retryable* infrastructure failure (500) from a *terminal* business decline (typed 402), and how `X-Fission-Workflow-Attempt` doubles as an idempotency key.
- **`functions/reject-order.js`** — one convergence point that inspects the accumulated document to explain *why* the order was rejected.
46 changes: 46 additions & 0 deletions miscellaneous/workflows/order-pipeline/functions/charge-card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Charges the customer's card. Demonstrates the two failure classes a
// payment step really has:
//
// - "declined" card -> 402 with a typed {errorType: "PaymentDeclined"}
// error. Retrying a decline is pointless, so the workflow catches this
// class and routes to the reject path instead of burning attempts.
// - "flaky-gateway" card -> 500 on the first attempt, success afterwards.
// A 5xx is a retryable infrastructure error (Fission.FunctionError); the
// state's retry policy re-invokes with backoff and the SECOND attempt
// succeeds. The engine passes the attempt number in the
// X-Fission-Workflow-Attempt header, which also serves as the
// idempotency key a real payment gateway would want.
module.exports = async function (context) {
const order = context.request.body || {};
const card = order.payment?.card || 'valid';
const attempt = parseInt(context.request.headers['x-fission-workflow-attempt'] || '1', 10);

if (card === 'declined') {
return {
status: 402,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
errorType: 'PaymentDeclined',
cause: { reason: 'insufficient funds', card: 'declined' },
}),
};
}

if (card === 'flaky-gateway' && attempt < 2) {
return {
status: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'payment gateway timeout (transient)' }),
};
}

return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chargeId: `ch_${order.orderId}_${attempt}`,
amount: order.total,
attempt,
}),
};
};
27 changes: 27 additions & 0 deletions miscellaneous/workflows/order-pipeline/functions/fraud-score.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Scores the order for fraud risk. Runs inside a Parallel branch, so it
// executes concurrently with the inventory check. Deterministic rules keep
// the demo reproducible: high totals and gift cards raise the score.
module.exports = async function (context) {
const order = context.request.body || {};
let score = 10;
const signals = [];

if ((order.total || 0) >= 5000) {
score += 60;
signals.push('order total >= 5000');
}
if ((order.items || []).some((i) => (i.sku || '').includes('giftcard'))) {
score += 40;
signals.push('contains gift cards');
}
if ((order.customer?.email || '').endsWith('@example.invalid')) {
score += 30;
signals.push('disposable email domain');
}

return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ riskScore: Math.min(score, 100), signals }),
};
};
16 changes: 16 additions & 0 deletions miscellaneous/workflows/order-pipeline/functions/fulfil-order.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Terminal happy-path step: hand the order to fulfilment. By this point the
// document has accumulated validation output, screening results (at
// $.screening) and the charge receipt (at $.charge) via resultPath.
module.exports = async function (context) {
const order = context.request.body || {};
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orderId: order.orderId,
status: 'FULFILLED',
chargeId: order.charge?.chargeId,
amount: order.charge?.amount,
}),
};
};
Loading