diff --git a/miscellaneous/examples.json b/miscellaneous/examples.json index b2841bb..4964a26 100644 --- a/miscellaneous/examples.json +++ b/miscellaneous/examples.json @@ -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" } ] \ No newline at end of file diff --git a/miscellaneous/workflows/README.md b/miscellaneous/workflows/README.md new file mode 100644 index 0000000..e2daa37 --- /dev/null +++ b/miscellaneous/workflows/README.md @@ -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 +fission workflow runs history --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 --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`). diff --git a/miscellaneous/workflows/batch-enrichment/README.md b/miscellaneous/workflows/batch-enrichment/README.md new file mode 100644 index 0000000..55f02c4 --- /dev/null +++ b/miscellaneous/workflows/batch-enrichment/README.md @@ -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 +``` + +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 ` 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. diff --git a/miscellaneous/workflows/batch-enrichment/functions/enrich-lead.js b/miscellaneous/workflows/batch-enrichment/functions/enrich-lead.js new file mode 100644 index 0000000..c4505c6 --- /dev/null +++ b/miscellaneous/workflows/batch-enrichment/functions/enrich-lead.js @@ -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 }), + }; +}; diff --git a/miscellaneous/workflows/batch-enrichment/functions/summarize-leads.js b/miscellaneous/workflows/batch-enrichment/functions/summarize-leads.js new file mode 100644 index 0000000..7d9505e --- /dev/null +++ b/miscellaneous/workflows/batch-enrichment/functions/summarize-leads.js @@ -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), + }), + }; +}; diff --git a/miscellaneous/workflows/batch-enrichment/inputs/leads.json b/miscellaneous/workflows/batch-enrichment/inputs/leads.json new file mode 100644 index 0000000..57bd5a4 --- /dev/null +++ b/miscellaneous/workflows/batch-enrichment/inputs/leads.json @@ -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" } + ] +} diff --git a/miscellaneous/workflows/batch-enrichment/workflow.yaml b/miscellaneous/workflows/batch-enrichment/workflow.yaml new file mode 100644 index 0000000..3d1d37b --- /dev/null +++ b/miscellaneous/workflows/batch-enrichment/workflow.yaml @@ -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 diff --git a/miscellaneous/workflows/order-pipeline/README.md b/miscellaneous/workflows/order-pipeline/README.md new file mode 100644 index 0000000..9fb4d4c --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/README.md @@ -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. diff --git a/miscellaneous/workflows/order-pipeline/functions/charge-card.js b/miscellaneous/workflows/order-pipeline/functions/charge-card.js new file mode 100644 index 0000000..5b3148b --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/charge-card.js @@ -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, + }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/functions/fraud-score.js b/miscellaneous/workflows/order-pipeline/functions/fraud-score.js new file mode 100644 index 0000000..13f2be1 --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/fraud-score.js @@ -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 }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/functions/fulfil-order.js b/miscellaneous/workflows/order-pipeline/functions/fulfil-order.js new file mode 100644 index 0000000..9dcaf1f --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/fulfil-order.js @@ -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, + }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/functions/inventory-check.js b/miscellaneous/workflows/order-pipeline/functions/inventory-check.js new file mode 100644 index 0000000..a06daaf --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/inventory-check.js @@ -0,0 +1,18 @@ +// Checks stock for every line item. Runs inside a Parallel branch alongside +// the fraud score. SKUs prefixed "oos-" are treated as out of stock so both +// outcomes can be demonstrated deterministically. +module.exports = async function (context) { + const order = context.request.body || {}; + const missing = (order.items || []) + .filter((i) => (i.sku || '').startsWith('oos-')) + .map((i) => i.sku); + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: missing.length > 0 ? 'OUT_OF_STOCK' : 'IN_STOCK', + missing, + }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/functions/reject-order.js b/miscellaneous/workflows/order-pipeline/functions/reject-order.js new file mode 100644 index 0000000..46b1214 --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/reject-order.js @@ -0,0 +1,24 @@ +// Terminal rejection step. Every failure route (invalid order, high fraud +// score, out of stock, declined card) converges here, so it inspects the +// accumulated document to say WHY the order was rejected. +module.exports = async function (context) { + const order = context.request.body || {}; + let reason = 'order could not be processed'; + + // Catch routes merge the error object at $.error (see workflow.yaml). + if (order.error?.errorType) { + reason = `${order.error.errorType}: ${JSON.stringify(order.error.cause)}`; + } + // Choice routes arrive with the screening results but no error object. + const screening = order.screening; + if (Array.isArray(screening)) { + if ((screening[0]?.riskScore || 0) > 70) reason = `fraud risk score ${screening[0].riskScore}`; + if (screening[1]?.status === 'OUT_OF_STOCK') reason = `out of stock: ${screening[1].missing.join(', ')}`; + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orderId: order.orderId || null, status: 'REJECTED', reason }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/functions/validate-order.js b/miscellaneous/workflows/order-pipeline/functions/validate-order.js new file mode 100644 index 0000000..136c53f --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/functions/validate-order.js @@ -0,0 +1,32 @@ +// Validates the incoming order document. A malformed order is a business +// error that retrying can never fix, so it is reported as a *typed* error +// ({errorType, cause} with a 4xx status) that the workflow's catch route +// sends straight to the reject path. +module.exports = async function (context) { + const order = context.request.body || {}; + const problems = []; + + if (!order.orderId) problems.push('orderId is required'); + if (!order.customer || !order.customer.email) problems.push('customer.email is required'); + if (!Array.isArray(order.items) || order.items.length === 0) problems.push('items must be a non-empty array'); + for (const item of order.items || []) { + if (!item.sku || !Number.isInteger(item.qty) || item.qty < 1) { + problems.push(`item ${JSON.stringify(item)} needs a sku and a positive integer qty`); + } + } + + if (problems.length > 0) { + return { + status: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ errorType: 'InvalidOrder', cause: { problems } }), + }; + } + + const total = order.items.reduce((sum, i) => sum + (i.unitPrice || 0) * i.qty, 0); + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...order, total, validatedAt: new Date().toISOString() }), + }; +}; diff --git a/miscellaneous/workflows/order-pipeline/inputs/declined-card.json b/miscellaneous/workflows/order-pipeline/inputs/declined-card.json new file mode 100644 index 0000000..c01e9a0 --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/declined-card.json @@ -0,0 +1,6 @@ +{ + "orderId": "ord-1003", + "customer": { "email": "lee@example.com" }, + "items": [{ "sku": "monitor-27", "qty": 1, "unitPrice": 349 }], + "payment": { "card": "declined" } +} diff --git a/miscellaneous/workflows/order-pipeline/inputs/flaky-gateway.json b/miscellaneous/workflows/order-pipeline/inputs/flaky-gateway.json new file mode 100644 index 0000000..a62e5d6 --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/flaky-gateway.json @@ -0,0 +1,6 @@ +{ + "orderId": "ord-1002", + "customer": { "email": "sam@example.com" }, + "items": [{ "sku": "desk-lamp", "qty": 1, "unitPrice": 89 }], + "payment": { "card": "flaky-gateway" } +} diff --git a/miscellaneous/workflows/order-pipeline/inputs/happy.json b/miscellaneous/workflows/order-pipeline/inputs/happy.json new file mode 100644 index 0000000..a86ebbf --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/happy.json @@ -0,0 +1,9 @@ +{ + "orderId": "ord-1001", + "customer": { "email": "dana@example.com" }, + "items": [ + { "sku": "kettle-steel", "qty": 1, "unitPrice": 49 }, + { "sku": "mug-blue", "qty": 4, "unitPrice": 9 } + ], + "payment": { "card": "valid" } +} diff --git a/miscellaneous/workflows/order-pipeline/inputs/high-fraud.json b/miscellaneous/workflows/order-pipeline/inputs/high-fraud.json new file mode 100644 index 0000000..a034b9a --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/high-fraud.json @@ -0,0 +1,6 @@ +{ + "orderId": "ord-1004", + "customer": { "email": "burner@example.invalid" }, + "items": [{ "sku": "giftcard-500", "qty": 12, "unitPrice": 500 }], + "payment": { "card": "valid" } +} diff --git a/miscellaneous/workflows/order-pipeline/inputs/invalid.json b/miscellaneous/workflows/order-pipeline/inputs/invalid.json new file mode 100644 index 0000000..5ba599c --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/invalid.json @@ -0,0 +1,4 @@ +{ + "customer": {}, + "items": [] +} diff --git a/miscellaneous/workflows/order-pipeline/inputs/out-of-stock.json b/miscellaneous/workflows/order-pipeline/inputs/out-of-stock.json new file mode 100644 index 0000000..90c1864 --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/inputs/out-of-stock.json @@ -0,0 +1,6 @@ +{ + "orderId": "ord-1005", + "customer": { "email": "kim@example.com" }, + "items": [{ "sku": "oos-standing-desk", "qty": 1, "unitPrice": 599 }], + "payment": { "card": "valid" } +} diff --git a/miscellaneous/workflows/order-pipeline/workflow.yaml b/miscellaneous/workflows/order-pipeline/workflow.yaml new file mode 100644 index 0000000..8e38aee --- /dev/null +++ b/miscellaneous/workflows/order-pipeline/workflow.yaml @@ -0,0 +1,85 @@ +apiVersion: fission.io/v1 +kind: Workflow +metadata: + name: order-pipeline +spec: + startAt: validate + timeout: 1h + # Keep the last 20 finished runs (and nothing older than a day) so the + # demo cluster doesn't accumulate history forever. + historyRetention: + maxCount: 20 + maxAge: 24h + states: + + # 1. Validation. A malformed order returns the typed error + # "InvalidOrder"; the catch route short-circuits to rejection. + validate: + type: Task + function: { name: wf-validate-order } + timeout: 30s + # resultPath merges the error at $.error so the reject step still sees + # the order document (omitting it would REPLACE the document with the + # error object — the Step-Functions default). + catch: + - errorType: InvalidOrder + next: reject + resultPath: $.error + next: screening + + # 2. Risk screening. Fraud scoring and the inventory check are + # independent, so they run concurrently in a Parallel state. The + # join is an ordered array — [fraudResult, inventoryResult] — + # merged into the order document at $.screening. + screening: + type: Parallel + branches: + - startAt: fraud + states: + fraud: { type: Task, function: { name: wf-fraud-score }, end: true } + - startAt: stock + states: + stock: { type: Task, function: { name: wf-inventory-check }, end: true } + resultPath: $.screening + next: decision + + # 3. Data-driven routing on the screening results. No function runs + # here — Choice is evaluated by the engine from the document. + decision: + type: Choice + choices: + - variable: $.screening[0].riskScore + numericGreaterThan: 70 + next: reject + - variable: $.screening[1].status + stringEquals: OUT_OF_STOCK + next: reject + default: charge + + # 4. Payment. Transient gateway errors (5xx) are retried with backoff; + # a decline is a typed business error that no retry can fix, so it + # is caught and routed to rejection instead. The receipt lands at + # $.charge, preserving the rest of the order document. + charge: + type: Task + function: { name: wf-charge-card } + timeout: 30s + retry: { maxAttempts: 3, backoffBase: 1s, backoffCap: 10s } + catch: + - errorType: PaymentDeclined + next: reject + resultPath: $.error + resultPath: $.charge + next: fulfil + + # 5a. Happy path. + fulfil: + type: Task + function: { name: wf-fulfil-order } + end: true + + # 5b. Every failure route converges here. + reject: + type: Task + function: { name: wf-reject-order } + end: true diff --git a/miscellaneous/workflows/payment-dunning/README.md b/miscellaneous/workflows/payment-dunning/README.md new file mode 100644 index 0000000..833a1ee --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/README.md @@ -0,0 +1,54 @@ +# Payment dunning — durable Wait timers + +Subscription renewal with a grace period: if the charge is declined, the run **waits out a grace period on a durable timer** and tries once more before cancelling the subscription. +In production the Wait would be days (`duration: 72h`); the example uses 15 seconds so you can watch it. + +The run consumes no pod, no memory and no connection while waiting — the timer lives in the statestore and survives controller restarts. + +```mermaid +stateDiagram-v2 + [*] --> first_attempt + first_attempt --> record: charged + first_attempt --> grace_period: PaymentDeclined + grace_period --> second_attempt: after 15s + second_attempt --> record: charged + second_attempt --> cancel: PaymentDeclined + record --> [*] + cancel --> [*] +``` + +## Deploy + +```bash +fission environment create --name nodejs --image ghcr.io/fission/node-env-22 # once + +for fn in charge-subscription record-payment cancel-subscription; do + fission function create --name wf-$fn --env nodejs --code functions/$fn.js +done + +fission workflow create -f workflow.yaml +``` + +## Run + +```bash +# Happy path: charged on the first attempt, run succeeds immediately. +fission workflow run --name payment-dunning --input @inputs/valid.json + +# Dunning path: declined -> 15s durable wait -> declined again -> CANCELLED. +fission workflow run --name payment-dunning --input @inputs/past-due.json +fission workflow runs list --workflow payment-dunning # phase stays Running during the wait +``` + +While the past-due run is in the grace period, `fission workflow runs describe --name ` shows the run parked in `grace-period`. +The history afterwards shows the `TimerFired` event between the two attempts: + +```bash +fission workflow runs history --name +``` + +To prove the wait is durable, restart the engine mid-wait — the run still completes: + +```bash +kubectl rollout restart deployment/workflow -n fission +``` diff --git a/miscellaneous/workflows/payment-dunning/functions/cancel-subscription.js b/miscellaneous/workflows/payment-dunning/functions/cancel-subscription.js new file mode 100644 index 0000000..d1e3b10 --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/functions/cancel-subscription.js @@ -0,0 +1,15 @@ +// Terminal dunning outcome: both charge attempts failed across the grace +// period, so the subscription is cancelled (and in a real system the +// customer notified / the account downgraded). +module.exports = async function (context) { + const sub = context.request.body || {}; + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: sub.subscriptionId, + status: 'CANCELLED', + reason: 'payment failed after grace period', + }), + }; +}; diff --git a/miscellaneous/workflows/payment-dunning/functions/charge-subscription.js b/miscellaneous/workflows/payment-dunning/functions/charge-subscription.js new file mode 100644 index 0000000..d543c24 --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/functions/charge-subscription.js @@ -0,0 +1,28 @@ +// Attempts to charge a subscription renewal. Cards behave deterministically +// so every dunning path can be demonstrated: +// "valid" -> charge succeeds immediately +// "past-due" -> always declined (typed error the workflow catches) +module.exports = async function (context) { + const sub = context.request.body || {}; + const attempt = parseInt(context.request.headers['x-fission-workflow-attempt'] || '1', 10); + + if ((sub.payment?.card || 'valid') === 'past-due') { + return { + status: 402, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + errorType: 'PaymentDeclined', + cause: { subscriptionId: sub.subscriptionId, reason: 'card past due' }, + }), + }; + } + + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...sub, + receipt: { chargeId: `ch_${sub.subscriptionId}_${Date.now()}`, attempt }, + }), + }; +}; diff --git a/miscellaneous/workflows/payment-dunning/functions/record-payment.js b/miscellaneous/workflows/payment-dunning/functions/record-payment.js new file mode 100644 index 0000000..27bfb23 --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/functions/record-payment.js @@ -0,0 +1,13 @@ +// Terminal happy-path step: persist the successful renewal. +module.exports = async function (context) { + const sub = context.request.body || {}; + return { + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: sub.subscriptionId, + status: 'RENEWED', + chargeId: sub.receipt?.chargeId, + }), + }; +}; diff --git a/miscellaneous/workflows/payment-dunning/inputs/past-due.json b/miscellaneous/workflows/payment-dunning/inputs/past-due.json new file mode 100644 index 0000000..77fe08a --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/inputs/past-due.json @@ -0,0 +1,6 @@ +{ + "subscriptionId": "sub-4711", + "plan": "team-annual", + "amount": 1188, + "payment": { "card": "past-due" } +} diff --git a/miscellaneous/workflows/payment-dunning/inputs/valid.json b/miscellaneous/workflows/payment-dunning/inputs/valid.json new file mode 100644 index 0000000..3a32a38 --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/inputs/valid.json @@ -0,0 +1,6 @@ +{ + "subscriptionId": "sub-4712", + "plan": "starter-monthly", + "amount": 29, + "payment": { "card": "valid" } +} diff --git a/miscellaneous/workflows/payment-dunning/workflow.yaml b/miscellaneous/workflows/payment-dunning/workflow.yaml new file mode 100644 index 0000000..48c0e45 --- /dev/null +++ b/miscellaneous/workflows/payment-dunning/workflow.yaml @@ -0,0 +1,55 @@ +apiVersion: fission.io/v1 +kind: Workflow +metadata: + name: payment-dunning +spec: + startAt: first-attempt + timeout: 2h + historyRetention: + maxCount: 20 + maxAge: 24h + states: + + # First renewal attempt. A decline is not an error to retry rapidly — + # the business process says: wait out a grace period, then try again. + first-attempt: + type: Task + function: { name: wf-charge-subscription } + timeout: 30s + # resultPath keeps the subscription document flowing (the error object + # merges at $.lastError instead of replacing the input) — the second + # attempt still needs the card and amount. + catch: + - errorType: PaymentDeclined + next: grace-period + resultPath: $.lastError + next: record + + # Durable timer. In production this would be days ("duration: 72h"); + # 15s keeps the demo watchable. The run consumes no resources while + # waiting, survives controller restarts, and fires exactly once. + grace-period: + type: Wait + duration: 15s + next: second-attempt + + # Retry after the grace period. A second decline ends the relationship. + second-attempt: + type: Task + function: { name: wf-charge-subscription } + timeout: 30s + catch: + - errorType: PaymentDeclined + next: cancel + resultPath: $.lastError + next: record + + record: + type: Task + function: { name: wf-record-payment } + end: true + + cancel: + type: Task + function: { name: wf-cancel-subscription } + end: true