From e9bdba02032d95a9a64846258f103adf06941890 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Tue, 1 Sep 2026 16:43:22 +0200 Subject: [PATCH 1/5] Document common function patterns Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- docs/languages/{go.md => go/index.md} | 5 + docs/languages/node.md | 5 + docs/languages/overview.md | 8 +- docs/languages/patterns/director.md | 1206 +++++++++++++++++++++++++ docs/languages/patterns/fan-out.md | 957 ++++++++++++++++++++ docs/languages/patterns/index.md | 230 +++++ docs/languages/patterns/singleton.md | 370 ++++++++ docs/languages/python/index.md | 3 + mkdocs.yml | 8 +- 9 files changed, 2790 insertions(+), 2 deletions(-) rename docs/languages/{go.md => go/index.md} (97%) create mode 100644 docs/languages/patterns/director.md create mode 100644 docs/languages/patterns/fan-out.md create mode 100644 docs/languages/patterns/index.md create mode 100644 docs/languages/patterns/singleton.md diff --git a/docs/languages/go.md b/docs/languages/go/index.md similarity index 97% rename from docs/languages/go.md rename to docs/languages/go/index.md index c873f2c9..0a037d58 100644 --- a/docs/languages/go.md +++ b/docs/languages/go/index.md @@ -406,3 +406,8 @@ func Handle(w http.ResponseWriter, r *http.Request) { } ``` +## Examples + +* [The director pattern](/languages/patterns/director/) +* [The fan-out pattern](/languages/patterns/fan-out/) +* [The singleton pattern](/languages/patterns/singleton/) diff --git a/docs/languages/node.md b/docs/languages/node.md index 2d30fcb0..5ba4fb47 100644 --- a/docs/languages/node.md +++ b/docs/languages/node.md @@ -353,3 +353,8 @@ functions: - The `NODE_OPTIONS` environment variable needs to have the value `--require @opentelemetry/auto-instrumentations-node/register` to register and initialize the auto instrumentation module. To see the full range of configuration options, see [Module Configuration](https://opentelemetry.io/docs/zero-code/js/configuration/). + +## Examples + +* [The director pattern](/languages/patterns/director/) +* [The fan-out pattern](/languages/patterns/fan-out/) diff --git a/docs/languages/overview.md b/docs/languages/overview.md index c6550317..27f15fd6 100644 --- a/docs/languages/overview.md +++ b/docs/languages/overview.md @@ -12,7 +12,7 @@ There are many community templates, of varying levels of support and maintenance There are a number of official templates maintained and recommended by OpenFaaS Ltd, the following are currently documented: -* [Go](./go.md) +* [Go](./go/index.md) * [Node](./node.md) * [Python](./python/index.md) * [Dockerfile](./dockerfile.md) @@ -34,3 +34,9 @@ You can also [create your own custom templates](./custom.md), or fork an existin You can bring along your own [pre-existing Dockerfiles and container images](/languages/dockerfile), so long as they conform to the [OpenFaaS workloads contract](/reference/workloads). You may need to add a health or readiness endpoint to make sure that no requests are lost during scaling up and down of your function. +### Function patterns + +After choosing a language, see the +[common function design patterns](/languages/patterns/) for ways to coordinate +functions into workflows, process work in parallel, or run a function with a +fixed replica count. diff --git a/docs/languages/patterns/director.md b/docs/languages/patterns/director.md new file mode 100644 index 00000000..1788718c --- /dev/null +++ b/docs/languages/patterns/director.md @@ -0,0 +1,1206 @@ +The [Director pattern](/languages/patterns/#director-pattern) implements a +workflow directly in an OpenFaaS function. The director controls how other +functions are invoked, passes data between them, combines results, and handles +errors. + +Use-cases: + +* Exposing a single endpoint for an operation that involves several functions +* Hiding the coordination between functions from the caller +* Coordinating functions that are developed, deployed, or scaled independently +* Keeping workflow decisions, error handling, and the final response in one + place + +This page implements the pattern as a telemetry workflow. The +`telemetry-workflow` function validates a sensor reading, runs temperature and +battery checks in parallel, and returns an `ok` or `alert` result: + +```text + [ Client ] + │ + ▼ POST /function/telemetry-workflow +┌──────────────────────┐ +│ telemetry-workflow │ ◄── director: owns the workflow and handles errors +└──────────┬───────────┘ + │ + ├── 1. Invoke ──► [ validate-reading ] + │◄──── validated reading ────────────┘ + │ + ├── 2. Invoke in parallel + │ ├──► [ temperature-check ] ──┐ + │ └──► [ battery-check ] ──────┤ + │◄──── check results ────────────────┘ + │ + └── 3. Combine results and return ok or alert +``` + +The workflow demonstrates three common Director operations: + +* **Sequence:** validation completes before any checks are started. +* **Parallel execution:** the temperature and battery checks run concurrently. +* **Conditional result:** the director returns `alert` when either check raises + an alert, otherwise it returns `ok`. + +## Create the functions + +Choose a language and scaffold the four functions in a single `stack.yaml` +file: + +=== "Go" + + ```bash + faas-cli template store pull golang-middleware + + faas-cli new --lang golang-middleware telemetry-workflow \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang golang-middleware validate-reading \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang golang-middleware temperature-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang golang-middleware battery-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files and `stack.yaml` with the Go files from + the implementation section below. + +=== "Python" + + ```bash + faas-cli template store pull python3-http + + faas-cli new --lang python3-http telemetry-workflow \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang python3-http validate-reading \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang python3-http temperature-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang python3-http battery-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files, `telemetry-workflow/requirements.txt`, + and `stack.yaml` with the Python files from the implementation section + below. + +=== "Node.js" + + ```bash + faas-cli template store pull node24 + + faas-cli new --lang node24 telemetry-workflow \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang node24 validate-reading \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang node24 temperature-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + + faas-cli new --lang node24 battery-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files and `stack.yaml` with the Node.js files + from the implementation section below. + +The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the +prefix with your own registry for production use. + +## Implement the workflow + +### Director: telemetry-workflow + +=== "Go" + + `telemetry-workflow/handler.go`: + + ```go + package function + + import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + ) + + const defaultStageTimeout = 5 * time.Second + + type Reading struct { + DeviceID string `json:"device_id"` + TemperatureC float64 `json:"temperature_c"` + BatteryPercent int `json:"battery_percent"` + } + + type TemperatureResult struct { + ValueC float64 `json:"value_c"` + ThresholdC float64 `json:"threshold_c"` + Alert bool `json:"alert"` + } + + type BatteryResult struct { + ValuePercent int `json:"value_percent"` + ThresholdPercent int `json:"threshold_percent"` + Alert bool `json:"alert"` + } + + type Response struct { + DeviceID string `json:"device_id"` + Status string `json:"status"` + Temperature TemperatureResult `json:"temperature"` + Battery BatteryResult `json:"battery"` + DurationMs int64 `json:"duration_ms"` + } + + type callResult struct { + function string + body []byte + status int + err error + } + + func Handle(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + input, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "unable to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + timeout, err := configuredStageTimeout() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + gateway := os.Getenv("gateway_url") + if gateway == "" { + gateway = "http://gateway.openfaas:8080" + } + + client := &http.Client{Timeout: timeout} + + validated, status, err := invoke( + r.Context(), client, gateway, "validate-reading", input, + ) + if err != nil { + message := fmt.Sprintf("failed to invoke validate-reading: %s", err) + http.Error(w, message, http.StatusBadGateway) + return + } + if status != http.StatusOK { + message := fmt.Sprintf("validate-reading failed: %s", validated) + http.Error(w, message, status) + return + } + + var reading Reading + if err := json.Unmarshal(validated, &reading); err != nil { + message := fmt.Sprintf( + "unexpected response from validate-reading: %s", + err, + ) + http.Error(w, message, http.StatusBadGateway) + return + } + + functions := []string{"temperature-check", "battery-check"} + results := make(chan callResult, len(functions)) + + for _, function := range functions { + go func(name string) { + body, status, err := invoke( + r.Context(), client, gateway, name, validated, + ) + results <- callResult{ + function: name, + body: body, + status: status, + err: err, + } + }(function) + } + + completed := make(map[string]callResult, len(functions)) + for range functions { + result := <-results + completed[result.function] = result + } + + for _, function := range functions { + result := completed[function] + if result.err != nil { + message := fmt.Sprintf( + "failed to invoke %s: %s", + function, + result.err, + ) + http.Error(w, message, http.StatusBadGateway) + return + } + if result.status != http.StatusOK { + message := fmt.Sprintf("%s failed: %s", function, result.body) + http.Error(w, message, result.status) + return + } + } + + var temperature TemperatureResult + if err := json.Unmarshal( + completed["temperature-check"].body, + &temperature, + ); err != nil { + message := fmt.Sprintf( + "unexpected response from temperature-check: %s", + err, + ) + http.Error(w, message, http.StatusBadGateway) + return + } + + var battery BatteryResult + if err := json.Unmarshal( + completed["battery-check"].body, + &battery, + ); err != nil { + message := fmt.Sprintf( + "unexpected response from battery-check: %s", + err, + ) + http.Error(w, message, http.StatusBadGateway) + return + } + + workflowStatus := "ok" + if temperature.Alert || battery.Alert { + workflowStatus = "alert" + } + + response := Response{ + DeviceID: reading.DeviceID, + Status: workflowStatus, + Temperature: temperature, + Battery: battery, + DurationMs: time.Since(start).Milliseconds(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + } + + func configuredStageTimeout() (time.Duration, error) { + value := os.Getenv("stage_timeout") + if value == "" { + return defaultStageTimeout, nil + } + + timeout, err := time.ParseDuration(value) + if err != nil || timeout <= 0 { + return 0, fmt.Errorf("invalid stage_timeout %q", value) + } + + return timeout, nil + } + + func invoke( + ctx context.Context, + client *http.Client, + gateway string, + function string, + body []byte, + ) ([]byte, int, error) { + url := strings.TrimRight(gateway, "/") + "/function/" + function + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + url, + bytes.NewReader(body), + ) + if err != nil { + return nil, 0, fmt.Errorf("create request for %s: %w", function, err) + } + req.Header.Set("Content-Type", "application/json") + + res, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("invoke %s: %w", function, err) + } + defer res.Body.Close() + + out, err := io.ReadAll(res.Body) + if err != nil { + return nil, 0, fmt.Errorf("read response from %s: %w", function, err) + } + + return out, res.StatusCode, nil + } + ``` + +=== "Python" + + `telemetry-workflow/handler.py`: + + ```python + import concurrent.futures + import os + import time + + import requests + + + GATEWAY_URL = os.getenv( + "gateway_url", "http://gateway.openfaas:8080" + ).rstrip("/") + STAGE_TIMEOUT = float(os.getenv("stage_timeout", "5")) + if STAGE_TIMEOUT <= 0: + raise ValueError("stage_timeout must be greater than zero") + + + def handle(event, context): + started = time.monotonic() + body = event.body + + try: + validated = invoke("validate-reading", body) + except requests.RequestException as err: + return error(502, f"failed to invoke validate-reading: {err}") + + if validated.status_code != 200: + return error( + validated.status_code, + f"validate-reading failed: {validated.text}", + ) + + try: + reading = validated.json() + except ValueError as err: + return error(502, f"unexpected response from validate-reading: {err}") + + functions = ("temperature-check", "battery-check") + completed = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + futures = { + name: executor.submit(invoke, name, validated.content) + for name in functions + } + for name, future in futures.items(): + try: + completed[name] = future.result() + except requests.RequestException as err: + return error(502, f"failed to invoke {name}: {err}") + + for name in functions: + response = completed[name] + if response.status_code != 200: + return error( + response.status_code, + f"{name} failed: {response.text}", + ) + + try: + temperature = completed["temperature-check"].json() + battery = completed["battery-check"].json() + except ValueError as err: + return error(502, f"unexpected response from check function: {err}") + + has_alert = temperature["alert"] or battery["alert"] + workflow_status = "alert" if has_alert else "ok" + return { + "statusCode": 200, + "body": { + "device_id": reading["device_id"], + "status": workflow_status, + "temperature": temperature, + "battery": battery, + "duration_ms": int((time.monotonic() - started) * 1000), + }, + } + + + def invoke(function, body): + return requests.post( + f"{GATEWAY_URL}/function/{function}", + data=body, + headers={"Content-Type": "application/json"}, + timeout=STAGE_TIMEOUT, + ) + + + def error(status_code, message): + return {"statusCode": status_code, "body": message.strip()} + ``` + + `telemetry-workflow/requirements.txt`: + + ```text + requests + ``` + +=== "Node.js" + + `telemetry-workflow/handler.js`: + + ```javascript + 'use strict' + + const { performance } = require('node:perf_hooks') + + const gatewayURL = process.env.gateway_url || + 'http://gateway.openfaas:8080' + const stageTimeout = configuredTimeout( + process.env.stage_timeout || '5', + 'stage_timeout' + ) + + module.exports = async (event, context) => { + const started = performance.now() + const input = requestBody(event.body) + + let validated + try { + validated = await invoke('validate-reading', input) + } catch (error) { + return fail( + context, + 502, + `failed to invoke validate-reading: ${error.message}` + ) + } + + if (validated.status !== 200) { + return fail( + context, + validated.status, + `validate-reading failed: ${await validated.text()}` + ) + } + + let reading + try { + reading = await validated.json() + } catch (error) { + return fail( + context, + 502, + `unexpected response from validate-reading: ${error.message}` + ) + } + + const body = JSON.stringify(reading) + const names = ['temperature-check', 'battery-check'] + let responses + try { + responses = await Promise.all( + names.map(async (name) => [name, await invoke(name, body)]) + ) + } catch (error) { + return fail(context, 502, `failed to invoke check: ${error.message}`) + } + + const completed = Object.fromEntries(responses) + for (const name of names) { + const response = completed[name] + if (response.status !== 200) { + return fail( + context, + response.status, + `${name} failed: ${await response.text()}` + ) + } + } + + let temperature + let battery + try { + temperature = await completed['temperature-check'].json() + battery = await completed['battery-check'].json() + } catch (error) { + return fail( + context, + 502, + `unexpected response from check function: ${error.message}` + ) + } + + const status = temperature.alert || battery.alert ? 'alert' : 'ok' + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ + device_id: reading.device_id, + status, + temperature, + battery, + duration_ms: Math.round(performance.now() - started) + }) + } + + function invoke (name, body) { + const gateway = gatewayURL.replace(/\/$/, '') + return fetch(`${gateway}/function/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: AbortSignal.timeout(stageTimeout) + }) + } + + function requestBody (body) { + if (Buffer.isBuffer(body)) { + return body + } + return typeof body === 'string' ? body : JSON.stringify(body) + } + + function configuredTimeout (value, name) { + const seconds = Number(value) + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`${name} must be greater than zero`) + } + return seconds * 1000 + } + + function fail (context, status, message) { + return context + .status(status) + .headers({ 'Content-Type': 'text/plain' }) + .succeed(message.trim()) + } + ``` + +The director invokes every stage through the gateway, so each stage can be +written in a different language and scaled independently. A stage transport +error returns `502 Bad Gateway`. A non-200 response is attributed to the stage +that returned it and passed through to the caller. + +The two checks are started concurrently, and the director waits for both +before choosing the final workflow status. + +### Stage: validate-reading + +The validation stage normalizes the device ID and rejects invalid values before +the parallel checks consume capacity. + +=== "Go" + + `validate-reading/handler.go`: + + ```go + package function + + import ( + "encoding/json" + "net/http" + "strings" + ) + + type Reading struct { + DeviceID string `json:"device_id"` + TemperatureC float64 `json:"temperature_c"` + BatteryPercent int `json:"battery_percent"` + } + + func Handle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var reading Reading + if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { + http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) + return + } + + reading.DeviceID = strings.TrimSpace(reading.DeviceID) + if reading.DeviceID == "" { + http.Error(w, "device_id is required", http.StatusBadRequest) + return + } + if reading.TemperatureC < -100 || reading.TemperatureC > 200 { + http.Error( + w, + "temperature_c must be between -100 and 200", + http.StatusBadRequest, + ) + return + } + if reading.BatteryPercent < 0 || reading.BatteryPercent > 100 { + http.Error( + w, + "battery_percent must be between 0 and 100", + http.StatusBadRequest, + ) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(reading) + } + ``` + +=== "Python" + + `validate-reading/handler.py`: + + ```python + import json + + + def handle(event, context): + try: + reading = json.loads(event.body) + except (TypeError, ValueError): + return error("expected a JSON sensor reading") + + device_id = str(reading.get("device_id", "")).strip() + temperature = reading.get("temperature_c", 0) + battery = reading.get("battery_percent", 0) + + if not device_id: + return error("device_id is required") + if not is_number(temperature) or temperature < -100 or temperature > 200: + return error("temperature_c must be between -100 and 200") + if ( + not isinstance(battery, int) + or isinstance(battery, bool) + or battery < 0 + or battery > 100 + ): + return error("battery_percent must be between 0 and 100") + + return { + "statusCode": 200, + "body": { + "device_id": device_id, + "temperature_c": temperature, + "battery_percent": battery, + }, + } + + + def is_number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + + def error(message): + return {"statusCode": 400, "body": message} + ``` + +=== "Node.js" + + `validate-reading/handler.js`: + + ```javascript + 'use strict' + + module.exports = async (event, context) => { + let reading + try { + reading = parseBody(event.body) + } catch (error) { + return fail(context, 'expected a JSON sensor reading') + } + + const deviceID = String(reading.device_id || '').trim() + const temperature = reading.temperature_c ?? 0 + const battery = reading.battery_percent ?? 0 + + if (!deviceID) { + return fail(context, 'device_id is required') + } + if ( + !Number.isFinite(temperature) || + temperature < -100 || + temperature > 200 + ) { + return fail( + context, + 'temperature_c must be between -100 and 200' + ) + } + if (!Number.isInteger(battery) || battery < 0 || battery > 100) { + return fail( + context, + 'battery_percent must be between 0 and 100' + ) + } + + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ + device_id: deviceID, + temperature_c: temperature, + battery_percent: battery + }) + } + + function parseBody (body) { + if (Buffer.isBuffer(body)) { + return JSON.parse(body.toString()) + } + return typeof body === 'string' ? JSON.parse(body) : body + } + + function fail (context, message) { + return context + .status(400) + .headers({ 'Content-Type': 'text/plain' }) + .succeed(message) + } + ``` + +### Parallel stages: temperature-check and battery-check + +The checks are intentionally small. In a real workflow they could call a model, +query device metadata, or apply rules maintained by another team. + +**Temperature check** + +=== "Go" + + `temperature-check/handler.go`: + + ```go + package function + + import ( + "encoding/json" + "net/http" + ) + + const thresholdC = 75.0 + + type Reading struct { + TemperatureC float64 `json:"temperature_c"` + } + + type Response struct { + ValueC float64 `json:"value_c"` + ThresholdC float64 `json:"threshold_c"` + Alert bool `json:"alert"` + } + + func Handle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var reading Reading + if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { + http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{ + ValueC: reading.TemperatureC, + ThresholdC: thresholdC, + Alert: reading.TemperatureC > thresholdC, + }) + } + ``` + +=== "Python" + + `temperature-check/handler.py`: + + ```python + import json + + + THRESHOLD_C = 75.0 + + + def handle(event, context): + try: + reading = json.loads(event.body) + value = reading["temperature_c"] + except (KeyError, TypeError, ValueError): + return {"statusCode": 400, "body": "expected a JSON sensor reading"} + + return { + "statusCode": 200, + "body": { + "value_c": value, + "threshold_c": THRESHOLD_C, + "alert": value > THRESHOLD_C, + }, + } + ``` + +=== "Node.js" + + `temperature-check/handler.js`: + + ```javascript + 'use strict' + + const thresholdC = 75.0 + + module.exports = async (event, context) => { + let reading + try { + reading = parseBody(event.body) + } catch (error) { + return fail(context) + } + + const value = reading.temperature_c + if (!Number.isFinite(value)) { + return fail(context) + } + + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ + value_c: value, + threshold_c: thresholdC, + alert: value > thresholdC + }) + } + + function parseBody (body) { + if (Buffer.isBuffer(body)) { + return JSON.parse(body.toString()) + } + return typeof body === 'string' ? JSON.parse(body) : body + } + + function fail (context) { + return context + .status(400) + .headers({ 'Content-Type': 'text/plain' }) + .succeed('expected a JSON sensor reading') + } + ``` + +**Battery check** + +=== "Go" + + `battery-check/handler.go`: + + ```go + package function + + import ( + "encoding/json" + "net/http" + ) + + const thresholdPercent = 20 + + type Reading struct { + BatteryPercent int `json:"battery_percent"` + } + + type Response struct { + ValuePercent int `json:"value_percent"` + ThresholdPercent int `json:"threshold_percent"` + Alert bool `json:"alert"` + } + + func Handle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + var reading Reading + if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { + http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Response{ + ValuePercent: reading.BatteryPercent, + ThresholdPercent: thresholdPercent, + Alert: reading.BatteryPercent < thresholdPercent, + }) + } + ``` + +=== "Python" + + `battery-check/handler.py`: + + ```python + import json + + + THRESHOLD_PERCENT = 20 + + + def handle(event, context): + try: + reading = json.loads(event.body) + value = reading["battery_percent"] + except (KeyError, TypeError, ValueError): + return {"statusCode": 400, "body": "expected a JSON sensor reading"} + + return { + "statusCode": 200, + "body": { + "value_percent": value, + "threshold_percent": THRESHOLD_PERCENT, + "alert": value < THRESHOLD_PERCENT, + }, + } + ``` + +=== "Node.js" + + `battery-check/handler.js`: + + ```javascript + 'use strict' + + const thresholdPercent = 20 + + module.exports = async (event, context) => { + let reading + try { + reading = parseBody(event.body) + } catch (error) { + return fail(context) + } + + const value = reading.battery_percent + if (!Number.isInteger(value)) { + return fail(context) + } + + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ + value_percent: value, + threshold_percent: thresholdPercent, + alert: value < thresholdPercent + }) + } + + function parseBody (body) { + if (Buffer.isBuffer(body)) { + return JSON.parse(body.toString()) + } + return typeof body === 'string' ? JSON.parse(body) : body + } + + function fail (context) { + return context + .status(400) + .headers({ 'Content-Type': 'text/plain' }) + .succeed('expected a JSON sensor reading') + } + ``` + +### Stack file + +=== "Go" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + telemetry-workflow: + lang: golang-middleware + handler: ./telemetry-workflow + image: ttl.sh/openfaas-examples/telemetry-workflow:latest + environment: + stage_timeout: 5s + exec_timeout: 15s + read_timeout: 16s + write_timeout: 16s + + validate-reading: + lang: golang-middleware + handler: ./validate-reading + image: ttl.sh/openfaas-examples/validate-reading:latest + + temperature-check: + lang: golang-middleware + handler: ./temperature-check + image: ttl.sh/openfaas-examples/temperature-check:latest + + battery-check: + lang: golang-middleware + handler: ./battery-check + image: ttl.sh/openfaas-examples/battery-check:latest + ``` + +=== "Python" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + telemetry-workflow: + lang: python3-http + handler: ./telemetry-workflow + image: ttl.sh/openfaas-examples/python-telemetry-workflow:latest + build_args: + TEST_ENABLED: "true" + environment: + stage_timeout: "5" + exec_timeout: 15s + read_timeout: 16s + write_timeout: 16s + + validate-reading: + lang: python3-http + handler: ./validate-reading + image: ttl.sh/openfaas-examples/python-validate-reading:latest + build_args: + TEST_ENABLED: "true" + + temperature-check: + lang: python3-http + handler: ./temperature-check + image: ttl.sh/openfaas-examples/python-temperature-check:latest + build_args: + TEST_ENABLED: "true" + + battery-check: + lang: python3-http + handler: ./battery-check + image: ttl.sh/openfaas-examples/python-battery-check:latest + build_args: + TEST_ENABLED: "true" + ``` + +=== "Node.js" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + telemetry-workflow: + lang: node24 + handler: ./telemetry-workflow + image: ttl.sh/openfaas-examples/node-telemetry-workflow:latest + environment: + stage_timeout: "5" + exec_timeout: 15s + read_timeout: 16s + write_timeout: 16s + + validate-reading: + lang: node24 + handler: ./validate-reading + image: ttl.sh/openfaas-examples/node-validate-reading:latest + + temperature-check: + lang: node24 + handler: ./temperature-check + image: ttl.sh/openfaas-examples/node-temperature-check:latest + + battery-check: + lang: node24 + handler: ./battery-check + image: ttl.sh/openfaas-examples/node-battery-check:latest + ``` + +## Configure timeouts + +A director stays active while it waits for the stages it invokes. This example +uses two kinds of timeout: + +| Setting | Scope | +|---------|-------| +| `stage_timeout` | Application-level HTTP client timeout for each downstream call | +| `exec_timeout` | Maximum duration of the complete director invocation | +| `read_timeout`, `write_timeout` | Watchdog HTTP timeouts, set slightly longer than `exec_timeout` | + +The director calls `validate-reading` first, followed by the two checks in +parallel. Its expected duration is therefore the validation duration plus the +slower of the two checks, with some additional overhead. Configure the +director's timeout for that complete path, not for a single stage. + +The gateway's `upstream_timeout` must be at least as long as the director's +`exec_timeout`. Adjust the example values for your own functions and see +[extended timeouts](/tutorials/expanded-timeouts/) for the complete +configuration. + +## Deploy and invoke + +Build, push, and deploy all four functions: + +```bash +faas-cli up --tag=sha +``` + +Invoke the director with a reading that exceeds both thresholds: + +```bash +curl -s http://127.0.0.1:8080/function/telemetry-workflow \ + -H "Content-Type: application/json" \ + -d '{"device_id":"pump-17","temperature_c":82.4,"battery_percent":12}' | \ + jq +``` + +The director combines the two check results and selects the `alert` path: + +```json +{ + "device_id": "pump-17", + "status": "alert", + "temperature": { + "value_c": 82.4, + "threshold_c": 75, + "alert": true + }, + "battery": { + "value_percent": 12, + "threshold_percent": 20, + "alert": true + }, + "duration_ms": 4 +} +``` + +Submit values within both thresholds to select the `ok` path: + +```bash +curl -s http://127.0.0.1:8080/function/telemetry-workflow \ + -H "Content-Type: application/json" \ + -d '{"device_id":"pump-17","temperature_c":48.2,"battery_percent":78}' | \ + jq +``` + +## Workflow considerations + +* A validation failure stops the workflow before either parallel check is + invoked. +* Parallel stages should be independent. If one stage needs the result of + another, invoke them in sequence instead. +* The final `ok` or `alert` decision is a small conditional branch. It could be + extended to invoke a notification function for alerts or a storage function + for normal readings. +* For a long-running workflow, invoke the director through + `/async-function/telemetry-workflow` with an `X-Callback-Url`. The director + continues to wait for its stages, while the client receives the final result + through the callback. See + [asynchronous functions](/reference/async/#how-it-works). diff --git a/docs/languages/patterns/fan-out.md b/docs/languages/patterns/fan-out.md new file mode 100644 index 00000000..24e61abb --- /dev/null +++ b/docs/languages/patterns/fan-out.md @@ -0,0 +1,957 @@ +The [Fan-out pattern](/languages/patterns/#fan-out-pattern) splits a larger +task into smaller, independent items that can be processed in parallel. + +Use-cases: + +* Processing a batch of independent items without keeping the caller waiting +* Absorbing bursts of work through a queue and processing them as capacity + becomes available +* Scaling the target function independently and sending each result to a + callback endpoint + +This page implements the pattern as a batch of URL health checks. The +`fan-out` function accepts one trusted URL per line and submits each URL as an +asynchronous invocation of the `url-check` function: + +```text + [ Client ] + │ + ▼ POST /function/fan-out +┌──────────────────────┐ +│ fan-out │ ◄── splits the batch and returns a summary +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ queue-worker │ ◄── drains the queue as capacity becomes available +└──────────┬───────────┘ + ├── URL 1 / async ──► [ url-check ] ──┐ + ├── URL 2 / async ──► [ url-check ] ──┤ + └── URL N / async ──► [ url-check ] ──┘ + │ optional callback + ▼ + [ Result endpoint ] +``` + +The example demonstrates three parts of fan-out: + +* **Submission:** the caller receives call IDs without waiting for the URL + checks to finish. +* **Queued processing:** the queue-worker invokes `url-check` as capacity + becomes available, and OpenFaaS can scale the function across replicas. +* **Result delivery:** an optional callback URL receives each health-check + result independently. + +## Create the functions + +Choose a language and scaffold both functions in a single `stack.yaml` file: + +=== "Go" + + ```bash + faas-cli template store pull golang-middleware + + faas-cli new --lang golang-middleware fan-out \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang golang-middleware url-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files and `stack.yaml` with the Go files from + the implementation section below. + +=== "Python" + + ```bash + faas-cli template store pull python3-http + + faas-cli new --lang python3-http fan-out \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang python3-http url-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files, both `requirements.txt` files, and + `stack.yaml` with the Python files from the implementation section below. + +=== "Node.js" + + ```bash + faas-cli template store pull node24 + + faas-cli new --lang node24 fan-out \ + --prefix ttl.sh/openfaas-examples + + faas-cli new --lang node24 url-check \ + --append stack.yaml --prefix ttl.sh/openfaas-examples + ``` + + Replace the generated handler files and `stack.yaml` with the Node.js files + from the implementation section below. + +The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the +prefix with your own registry for production use. + +## Implement the functions + +### Submitting function: fan-out + +=== "Go" + + `fan-out/handler.go`: + + ```go + package function + + import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + ) + + const ( + targetFunction = "url-check" + submitTimeout = 30 * time.Second + ) + + type Response struct { + Submitted int `json:"submitted"` + Function string `json:"function"` + Callback bool `json:"callback"` + CallIDs []string `json:"call_ids,omitempty"` + } + + // Handle takes a HTTP request body and splits it into one record per line. + // Each record is submitted as an asynchronous invocation of the target + // function, then a summary is returned to the caller without waiting for + // the function invocations to complete. + func Handle(w http.ResponseWriter, r *http.Request) { + input, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "unable to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + gateway := os.Getenv("gateway_url") + if gateway == "" { + gateway = "http://gateway.openfaas:8080" + } + + // Forward the callback URL to every asynchronous invocation. A header on + // the batch request overrides the environment variable. + callback := strings.TrimSpace(r.Header.Get("X-Callback-Url")) + if callback == "" { + callback = strings.TrimSpace(os.Getenv("callback_url")) + } + + records := recordsFromInput(string(input)) + if len(records) == 0 { + http.Error( + w, + "expected one record per line in the request body", + http.StatusBadRequest, + ) + return + } + + submitted := 0 + var callIDs []string + + for i, record := range records { + callID, err := submit( + r.Context(), gateway, targetFunction, record, callback, + ) + if err != nil { + message := fmt.Sprintf( + "record %d of %d: %s", + i+1, + len(records), + err, + ) + http.Error(w, message, http.StatusBadGateway) + return + } + + submitted++ + if callID != "" { + callIDs = append(callIDs, callID) + } + } + + res := Response{ + Submitted: submitted, + Function: targetFunction, + Callback: callback != "", + CallIDs: callIDs, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) + } + + func recordsFromInput(input string) []string { + var records []string + + for _, record := range strings.Split(strings.TrimSpace(input), "\n") { + record = strings.TrimSpace(record) + if record != "" { + records = append(records, record) + } + } + + return records + } + + func submit( + ctx context.Context, + gateway string, + targetFunction string, + record string, + callback string, + ) (string, error) { + ctx, cancel := context.WithTimeout(ctx, submitTimeout) + defer cancel() + + url := strings.TrimRight(gateway, "/") + "/async-function/" + targetFunction + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + url, + bytes.NewReader([]byte(record)), + ) + if err != nil { + return "", fmt.Errorf("unable to invoke %s: %w", targetFunction, err) + } + req.Header.Set("Content-Type", "text/plain") + if callback != "" { + req.Header.Set("X-Callback-Url", callback) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("error invoking %s: %w", targetFunction, err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusAccepted { + out, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf( + "unexpected status %d from %s", + res.StatusCode, + targetFunction, + ) + } + + return "", fmt.Errorf( + "unexpected status %d from %s: %s", + res.StatusCode, + targetFunction, + string(out), + ) + } + + // the X-Call-Id header can be used to track or cancel the record + return res.Header.Get("X-Call-Id"), nil + } + ``` + +=== "Python" + + `fan-out/handler.py`: + + ```python + import os + + import requests + + + TARGET_FUNCTION = "url-check" + SUBMIT_TIMEOUT = 30 + + + def handle(event, context): + body = ( + event.body.decode() + if isinstance(event.body, bytes) + else str(event.body) + ) + records = [ + record.strip() + for record in body.strip().splitlines() + if record.strip() + ] + if not records: + return error(400, "expected one record per line in the request body") + + gateway = os.getenv("gateway_url", "http://gateway.openfaas:8080") + callback = event.headers.get("X-Callback-Url", "").strip() + if not callback: + callback = os.getenv("callback_url", "").strip() + + call_ids = [] + for index, record in enumerate(records): + try: + call_id = submit(gateway, record, callback) + except requests.RequestException as err: + return error(502, f"record {index + 1} of {len(records)}: {err}") + except RuntimeError as err: + return error(502, f"record {index + 1} of {len(records)}: {err}") + + if call_id: + call_ids.append(call_id) + + response = { + "submitted": len(records), + "function": TARGET_FUNCTION, + "callback": bool(callback), + } + if call_ids: + response["call_ids"] = call_ids + + return {"statusCode": 200, "body": response} + + + def submit(gateway, record, callback): + headers = {"Content-Type": "text/plain"} + if callback: + headers["X-Callback-Url"] = callback + + response = requests.post( + f"{gateway.rstrip('/')}/async-function/{TARGET_FUNCTION}", + data=record.encode(), + headers=headers, + timeout=SUBMIT_TIMEOUT, + ) + if response.status_code != 202: + raise RuntimeError( + f"unexpected status {response.status_code} " + f"from {TARGET_FUNCTION}: {response.text}" + ) + + return response.headers.get("X-Call-Id", "") + + + def error(status_code, message): + return {"statusCode": status_code, "body": message} + ``` + + `fan-out/requirements.txt`: + + ```text + requests + ``` + +=== "Node.js" + + `fan-out/handler.js`: + + ```javascript + 'use strict' + + const targetFunction = 'url-check' + const submitTimeout = 30000 + + module.exports = async (event, context) => { + const input = requestBody(event.body) + const records = input + .trim() + .split('\n') + .map((record) => record.trim()) + .filter(Boolean) + + if (records.length === 0) { + return fail( + context, + 400, + 'expected one record per line in the request body' + ) + } + + const gateway = process.env.gateway_url || + 'http://gateway.openfaas:8080' + const headers = event.headers || {} + const callback = String( + headers['x-callback-url'] || process.env.callback_url || '' + ).trim() + + const callIDs = [] + for (const [index, record] of records.entries()) { + let callID + try { + callID = await submit(gateway, record, callback) + } catch (error) { + return fail( + context, + 502, + `record ${index + 1} of ${records.length}: ${error.message}` + ) + } + if (callID) { + callIDs.push(callID) + } + } + + const response = { + submitted: records.length, + function: targetFunction, + callback: Boolean(callback) + } + if (callIDs.length > 0) { + response.call_ids = callIDs + } + + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed(response) + } + + async function submit (gateway, record, callback) { + const headers = { 'Content-Type': 'text/plain' } + if (callback) { + headers['X-Callback-Url'] = callback + } + + const baseURL = gateway.replace(/\/$/, '') + const response = await fetch( + `${baseURL}/async-function/${targetFunction}`, + { + method: 'POST', + headers, + body: record, + signal: AbortSignal.timeout(submitTimeout) + } + ) + + if (response.status !== 202) { + const body = await response.text() + throw new Error( + `unexpected status ${response.status} ` + + `from ${targetFunction}: ${body}` + ) + } + + return response.headers.get('X-Call-Id') || '' + } + + function requestBody (body) { + if (Buffer.isBuffer(body)) { + return body.toString() + } + return String(body || '') + } + + function fail (context, status, message) { + return context + .status(status) + .headers({ 'Content-Type': 'text/plain' }) + .succeed(message) + } + ``` + +The submitting function: + +* Uses the in-cluster gateway URL by default and submits each URL through + `/async-function/url-check`. +* Forwards `X-Callback-Url` from the batch request to every asynchronous + invocation. The `callback_url` environment variable can provide a default. +* Returns the `X-Call-Id` from each accepted submission so individual checks + can be tracked or cancelled. +* Stops and returns `502 Bad Gateway` if the gateway does not accept one of the + submissions. Checks accepted before that failure remain queued. + +### Fanned-out function: url-check + +The function performs an HTTP `GET` with a configurable timeout and returns a +structured health result. + +=== "Go" + + `url-check/handler.go`: + + ```go + package function + + import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + ) + + const ( + defaultRequestTimeout = 5 * time.Second + maxURLLength = 4096 + ) + + var requestTimeout = defaultRequestTimeout + + func init() { + value := os.Getenv("request_timeout") + if value == "" { + return + } + + timeout, err := time.ParseDuration(value) + if err != nil || timeout <= 0 { + panic(fmt.Sprintf("invalid request_timeout %q", value)) + } + + requestTimeout = timeout + } + + type Response struct { + URL string `json:"url"` + Reachable bool `json:"reachable"` + Healthy bool `json:"healthy"` + StatusCode int `json:"status_code,omitempty"` + ContentType string `json:"content_type,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` + } + + func Handle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + input, err := io.ReadAll(io.LimitReader(r.Body, maxURLLength+1)) + if err != nil { + http.Error(w, "unable to read request body", http.StatusBadRequest) + return + } + if len(input) > maxURLLength { + http.Error(w, "URL is too long", http.StatusBadRequest) + return + } + + target := strings.TrimSpace(string(input)) + parsed, err := url.ParseRequestURI(target) + if err != nil || parsed.Host == "" { + http.Error( + w, + "expected an absolute HTTP or HTTPS URL", + http.StatusBadRequest, + ) + return + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + http.Error( + w, + "expected an absolute HTTP or HTTPS URL", + http.StatusBadRequest, + ) + return + } + + start := time.Now() + ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + http.Error( + w, + "unable to create health-check request", + http.StatusBadRequest, + ) + return + } + req.Header.Set("User-Agent", "OpenFaaS URL health check") + + res, requestErr := http.DefaultClient.Do(req) + result := Response{ + URL: target, + DurationMs: time.Since(start).Milliseconds(), + } + if requestErr != nil { + result.Error = requestErr.Error() + writeJSON(w, result) + return + } + defer res.Body.Close() + io.Copy(io.Discard, io.LimitReader(res.Body, 1024)) + + result.Reachable = true + result.Healthy = res.StatusCode >= 200 && res.StatusCode < 400 + result.StatusCode = res.StatusCode + result.ContentType = res.Header.Get("Content-Type") + writeJSON(w, result) + } + + func writeJSON(w http.ResponseWriter, result Response) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + } + ``` + +=== "Python" + + `url-check/handler.py`: + + ```python + import os + import time + from urllib.parse import urlparse + + import requests + + + MAX_URL_LENGTH = 4096 + REQUEST_TIMEOUT = float(os.getenv("request_timeout", "5")) + if REQUEST_TIMEOUT <= 0: + raise ValueError("request_timeout must be greater than zero") + + + def handle(event, context): + body = ( + event.body + if isinstance(event.body, bytes) + else str(event.body).encode() + ) + if len(body) > MAX_URL_LENGTH: + return error("URL is too long") + + target = body.decode().strip() + parsed = urlparse(target) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return error("expected an absolute HTTP or HTTPS URL") + + started = time.monotonic() + result = { + "url": target, + "reachable": False, + "healthy": False, + } + + try: + with requests.get( + target, + headers={"User-Agent": "OpenFaaS URL health check"}, + timeout=REQUEST_TIMEOUT, + stream=True, + ) as response: + response.raw.read(1024) + result.update( + { + "reachable": True, + "healthy": 200 <= response.status_code < 400, + "status_code": response.status_code, + "content_type": response.headers.get("Content-Type", ""), + "duration_ms": int((time.monotonic() - started) * 1000), + } + ) + except requests.RequestException as err: + result["duration_ms"] = int((time.monotonic() - started) * 1000) + result["error"] = str(err) + return {"statusCode": 200, "body": result} + + return {"statusCode": 200, "body": result} + + + def error(message): + return {"statusCode": 400, "body": message} + ``` + + `url-check/requirements.txt`: + + ```text + requests + ``` + +=== "Node.js" + + `url-check/handler.js`: + + ```javascript + 'use strict' + + const { performance } = require('node:perf_hooks') + + const maxURLLength = 4096 + const requestTimeout = configuredTimeout( + process.env.request_timeout || '5', + 'request_timeout' + ) + + module.exports = async (event, context) => { + const target = requestBody(event.body).trim() + if (Buffer.byteLength(target) > maxURLLength) { + return fail(context, 'URL is too long') + } + + let parsed + try { + parsed = new URL(target) + } catch (error) { + return fail(context, 'expected an absolute HTTP or HTTPS URL') + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return fail(context, 'expected an absolute HTTP or HTTPS URL') + } + + const started = performance.now() + const result = { + url: target, + reachable: false, + healthy: false + } + + let response + try { + response = await fetch(target, { + headers: { 'User-Agent': 'OpenFaaS URL health check' }, + signal: AbortSignal.timeout(requestTimeout) + }) + } catch (error) { + result.duration_ms = Math.round(performance.now() - started) + result.error = error.message + return succeed(context, result) + } + + if (response.body) { + await response.body.cancel() + } + result.reachable = true + result.healthy = response.status >= 200 && response.status < 400 + result.status_code = response.status + result.content_type = response.headers.get('Content-Type') || '' + result.duration_ms = Math.round(performance.now() - started) + return succeed(context, result) + } + + function requestBody (body) { + if (Buffer.isBuffer(body)) { + return body.toString() + } + return String(body || '') + } + + function configuredTimeout (value, name) { + const seconds = Number(value) + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`${name} must be greater than zero`) + } + return seconds * 1000 + } + + function succeed (context, body) { + return context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed(body) + } + + function fail (context, message) { + return context + .status(400) + .headers({ 'Content-Type': 'text/plain' }) + .succeed(message) + } + ``` + +Each URL produces a structured result, including unreachable targets and +timeouts, so every outcome can be delivered to the callback endpoint. + +!!! warning + + Only submit URLs from a trusted source. Fetching arbitrary user-provided + URLs can expose internal services through server-side request forgery + (SSRF). For a public endpoint, enforce an allow-list and validate resolved + addresses before making the request. + +### Stack file + +=== "Go" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-out: + lang: golang-middleware + handler: ./fan-out + image: ttl.sh/openfaas-examples/fan-out:latest + + url-check: + lang: golang-middleware + handler: ./url-check + image: ttl.sh/openfaas-examples/url-check:latest + environment: + request_timeout: 5s + ``` + +=== "Python" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-out: + lang: python3-http + handler: ./fan-out + image: ttl.sh/openfaas-examples/python-fan-out:latest + build_args: + TEST_ENABLED: "true" + + url-check: + lang: python3-http + handler: ./url-check + image: ttl.sh/openfaas-examples/python-url-check:latest + build_args: + TEST_ENABLED: "true" + environment: + request_timeout: "5" + ``` + +=== "Node.js" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-out: + lang: node24 + handler: ./fan-out + image: ttl.sh/openfaas-examples/node-fan-out:latest + + url-check: + lang: node24 + handler: ./url-check + image: ttl.sh/openfaas-examples/node-url-check:latest + environment: + request_timeout: "5" + ``` + +## Deploy and submit a batch + +Build, push, and deploy both functions: + +```bash +faas-cli up --tag=sha +``` + +The handler splits the request body on newlines, so use `--data-binary` with `curl`: + +```bash +printf 'https://www.openfaas.com/\nhttps://docs.openfaas.com/\n' | \ + curl -s --data-binary @- -H "Content-Type: text/plain" \ + http://127.0.0.1:8080/function/fan-out | jq +``` + +The response confirms that both checks were accepted without waiting for them +to finish: + +```json +{ + "submitted": 2, + "function": "url-check", + "callback": false, + "call_ids": [ + "9c0b1a12-fdea-4f01-baff-c5d9f50435ea", + "4111d512-cdf3-4b8f-96b3-1b7f1f376bd7" + ] +} +``` + +## Collect individual results with a callback + +By default, the queue-worker discards the response from each `url-check` +invocation. To receive the responses, set `X-Callback-Url` on the request to +`fan-out`. The submitting function copies that URL to every queued invocation, +and the queue-worker posts each result to the callback endpoint. + +Callbacks are independent and may arrive in a different order from the input. +This example delivers each result but does not wait for or combine the whole +batch. When that is required, the callback endpoint can use shared storage to +track progress and [fan the results back in](https://www.openfaas.com/blog/fan-out-and-back-in-using-functions/). + +### Receive callback results + +Deploy the `printer` function from the store and follow its logs: + +```bash +faas-cli store deploy printer +faas-cli logs printer -t +``` + +In another terminal, submit the batch with a callback URL: + +```bash +printf 'https://www.openfaas.com/\nhttps://docs.openfaas.com/\n' | \ + curl -s --data-binary @- \ + -H "Content-Type: text/plain" \ + -H "X-Callback-Url: http://gateway.openfaas:8080/function/printer" \ + http://127.0.0.1:8080/function/fan-out | jq +``` + +The batch response now contains `"callback": true`. The `printer` logs receive +one callback per URL, with a body similar to: + +```json +{ + "url": "https://www.openfaas.com/", + "reachable": true, + "healthy": true, + "status_code": 200, + "content_type": "text/html; charset=utf-8", + "duration_ms": 84 +} +``` + +The `printer` function is useful for demonstrating callback delivery. Replace +it with an application endpoint when results need to be persisted or acted on. + +## Track and cancel checks + +Each call ID in the batch response identifies one queued check. Cancel it with +a `DELETE` request to the async endpoint: + +```bash +curl -i -X DELETE \ + http://127.0.0.1:8080/async-function/9c0b1a12-fdea-4f01-baff-c5d9f50435ea +``` + +A `202 Accepted` response indicates that the cancellation request was +accepted. See [asynchronous functions](/reference/async/) for the complete +lifecycle. + +## Operational considerations + +* The queue-worker processes records up to its configured `max_inflight` + concurrency, while OpenFaaS can + [autoscale the function](/architecture/autoscaling/) across replicas. See + [parallelism](/reference/async/#parallelism). +* A target that cannot be reached produces a successful function invocation + with `"reachable": false`. This allows the failure result to reach the + callback rather than being retried as a function error. +* Queue-worker retries apply when the function invocation itself fails. See + [retries](/openfaas-pro/retries/). +* The maximum payload size for each queued item is 1MB. For larger inputs, + store the data externally and submit an identifier. See + [configuration and limits](/reference/async/#configuration-limits). diff --git a/docs/languages/patterns/index.md b/docs/languages/patterns/index.md new file mode 100644 index 00000000..9c16679c --- /dev/null +++ b/docs/languages/patterns/index.md @@ -0,0 +1,230 @@ +OpenFaaS functions can be combined into workflows, run in the background or in +parallel, or use a fixed replica count. This page introduces common patterns +that work with any language template. + +| Pattern | Summary | Useful when | Example | +|---------|-----------|-------------|---------| +| [Director](#director-pattern) | One function coordinates several other functions | A workflow needs sequencing, branching, parallel function calls, or a combined response | [View example](/languages/patterns/director/) | +| [Async invocation](#async-invocation-pattern) | The gateway queues an invocation for async processing | Work should continue in the background, or queued processing is needed for back pressure | — | +| [Fan-out](#fan-out-pattern) | One invocation is divided into many independent asynchronous invocations | A batch needs concurrent processing that can scale across multiple function replicas | [View example](/languages/patterns/fan-out/) | +| [Singleton](#singleton-pattern) | A function is kept at one replica | State is tied to a connection, or the function must be limited to a single replica | [View example](/languages/patterns/singleton/) | + +The patterns can also be combined. For example, a director can fan out a batch +to another function, or an asynchronous invocation can start a director without +keeping the original caller connected. + +## Director pattern + +A director is an OpenFaaS function that coordinates a workflow. It invokes +other functions through the gateway, passes results between them, decides what +runs next, and returns or stores the final result. + +The functions invoked by the director are deployed independently, so each can +use a different language, scale separately, and be updated without moving the +workflow logic out of the director. + +```text + [ Client ] + │ + ▼ HTTP POST / invoke +┌──────────────────────┐ +│ Director Function │ ◄── Owns the workflow +└──────────┬───────────┘ + │ + ├── 1. Invoke ──► [ Function A ] + │ │ + │◄──── response ─────────┘ + │ + ├── 2. Branch on result + │ ├── if X ──► [ Function B ] + │ └── if Y ──► [ Function C ] + │ + ├── 3. Invoke independent functions in parallel + │ ├──► [ Function D ] ──┐ + │ └──► [ Function E ] ──┤ + │ ▼ + │ [ Combine results ] + │ + ▼ +[ Return response / persist result ] +``` + +**Useful when:** + +* Several independently deployed functions form one logical operation. +* The caller needs one endpoint and one combined response. +* A workflow needs sequencing, branching, or independent functions to run + in parallel. + +**Design considerations:** + +* The director remains active while it waits for downstream functions to + finish. Its [timeouts](/tutorials/expanded-timeouts/) should be configured to + cover the longest expected path through the complete workflow, rather than a + single downstream function call. +* The director decides how to handle errors from the functions it invokes. It + can stop, retry, return a partial result, or save its progress. +* For a long-running workflow, the director can be + [invoked asynchronously](#async-invocation-pattern), so the caller does not + need to wait. The director can send the final result to a callback URL when + the workflow completes. + +**Examples:** + +* [Coordinate a workflow with a director](/languages/patterns/director/) + +## Async invocation pattern + +Any OpenFaaS function can be invoked asynchronously by changing the gateway +route from `/function/` to `/async-function/`. The caller sends an +HTTP POST and immediately receives `202 Accepted`. The request is added to a +queue and processed later by the queue-worker. + +This decouples the HTTP request from the function response, so the caller does +not need to wait while long-running work, background jobs, or batches are +processed. + +An optional callback can receive the result when processing +completes. + +```text + [ Client ] ──► [ Gateway ] ──► [ Queue ] + ▲ │ │ + └──── 202 ─────┘ ▼ + [ Queue-worker ] ──► [ Function ] + │ + └── callback ──► [ Result endpoint ] +``` + +**Useful when:** + +* Work takes longer than the caller should wait. +* Queued processing provides back pressure when requests arrive faster than + available capacity. +* Failed asynchronous invocations need to be + [retried automatically](/openfaas-pro/retries/) with a back-off. + +See [asynchronous functions](/reference/async/) for more information on +invocations, callbacks, retries and queue configuration. + +## Fan-out pattern + +Fan-out combines asynchronous invocation with independently scalable +functions. One function divides a batch into independent items and invokes a +worker function asynchronously for each item. It returns after the invocations +are queued rather than waiting for them to finish. + +The queue-worker drains the queued items as capacity becomes available, while +OpenFaaS can [autoscale the target function](/architecture/autoscaling/) across +multiple replicas. + +The queue-worker automatically +[retries failed invocations](/openfaas-pro/retries/), allowing batch items to +complete even when an invocation fails. + +```text + [ Input batch ] + │ + ▼ +┌────────────────────────┐ +│ Fan-out Function │ +└────────────┬───────────┘ + │ + ▼ +┌────────────────────────┐ +│ Queue-worker │ +└────────────┬───────────┘ + ├── item 1 / async ──► [ Function ] ──┐ + ├── item 2 / async ──► [ Function ] ──┤ + └── item N / async ──► [ Function ] ──┘ + │ callback + ▼ + [ Result collector ] +``` + +**Useful when:** + +* A batch contains records that can be processed independently. +* Processing should happen concurrently and can complete out of order. +* Processing capacity needs to scale across multiple worker function replicas, + independently of the function that submits the batch. + +**Design considerations:** + +* Items must be independent. If one item needs another item's result, process + them in sequence instead. +* Callbacks can deliver each result independently and may arrive in a different + order from the input. + +**Fan-in:** + +When the next step depends on every item completing, the individual +results can be fanned back in. One way to implement this is to: + +* Set a counter to the number of items in the batch. +* Store each callback result in shared storage and atomically decrement the + counter. +* When the counter reaches zero, invoke a final function to combine the stored + results, send a notification, or start the next step in the workflow. + +When results do not need to be combined, each callback can be handled +independently. + +**Examples:** + +* [Process a batch with fan-out](/languages/patterns/fan-out/) +* [Combine individual results with fan-out and fan-in](https://www.openfaas.com/blog/fan-out-and-back-in-using-functions/). + +## Singleton pattern + +A singleton function is configured with a fixed replica count of one. It can be +useful when a workload keeps connection-local state, wraps software that cannot +run concurrently, or must limit access to an external resource. + +```text + [ Requests ] + │ + ▼ HTTP / invoke +┌──────────────────────────┐ +│ Singleton Function │ ◄── Fixed at one replica +│ │ +│ [ Replica 1 ] │ +└──────────────────────────┘ +``` + +Set the minimum and maximum replica labels to the same value to disable +horizontal scaling for the function: + +```yaml +functions: + stateful-function: + labels: + com.openfaas.scale.min: 1 + com.openfaas.scale.max: 1 +``` + +**Useful when:** + +* Connections or subscribers are stored in the function process. +* Software or an external resource requires a fixed number of function + replicas. +* Horizontal scaling is intentionally undesirable. + +**Design considerations:** + +* A fixed replica count keeps one replica running during normal operation, but + does not make the function or its local filesystem durable. The replica can + be replaced during a restart, rescheduling, or deployment, so state that + needs to survive these events should still be stored externally. +* The function cannot add replicas to increase its capacity. +* If the goal is to process one request at a time, set the `max_inflight: 1` + environment variable on the function. Fixing the replica count alone does + not prevent concurrent requests within the replica. + +For more information on configuring function scaling, see the +[autoscaling documentation](/architecture/autoscaling/). + +**Examples:** + +* [Run a function as a singleton](/languages/patterns/singleton/) +* [Apply singleton scaling to WebSocket connections](https://www.openfaas.com/blog/serverless-websockets/#scaling-websockets). diff --git a/docs/languages/patterns/singleton.md b/docs/languages/patterns/singleton.md new file mode 100644 index 00000000..30d674f7 --- /dev/null +++ b/docs/languages/patterns/singleton.md @@ -0,0 +1,370 @@ +The [Singleton pattern](/languages/patterns/#singleton-pattern) fixes a +function's desired replica count at one. This example demonstrates the pattern +with a small Server-Sent Events (SSE) notification hub. Singleton functions are +useful when a workload keeps connection-local state or wraps software that +cannot run concurrently. + +Use-cases: + +* Keeping connection-local state, such as SSE subscribers or WebSocket sessions +* Wrapping software that cannot safely run concurrently +* Limiting access to an external resource that permits one active client + +## How it works + +The `notification-hub` function accepts two kinds of request: + +```text +[ Publisher ] ── POST ──► [ notification-hub ] ── SSE ──► [ Subscribers ] + one replica +``` + +`GET` opens an SSE subscription, while `POST` broadcasts its request body to +all connected subscribers. + +The function runs with one replica, so every subscription is registered in the +same in-memory map and each notification can reach all connected subscribers. + +## Create the function + +Choose a language and scaffold the function: + +=== "Go" + + ```bash + faas-cli template store pull golang-middleware + + faas-cli new --lang golang-middleware notification-hub \ + --prefix ttl.sh/openfaas-examples + ``` + +=== "Python" + + ```bash + faas-cli template store pull python3-flask + + faas-cli new --lang python3-flask notification-hub \ + --prefix ttl.sh/openfaas-examples + ``` + +Replace the generated handler and `stack.yaml` with the files from the +implementation section below. + +The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the +prefix with your own registry for production use. + +## Implement the function + +=== "Go" + + `handler.go`: + + ```go + package function + + import ( + "fmt" + "io" + "net/http" + "strings" + "sync" + ) + + var ( + subscribersMu sync.Mutex + subscribers = make(map[chan string]struct{}) + ) + + func Handle(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + subscribe(w, r) + case http.MethodPost: + publish(w, r) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } + + func subscribe(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error( + w, + "streaming is not supported", + http.StatusInternalServerError, + ) + return + } + + messages := make(chan string, 1) + subscribersMu.Lock() + subscribers[messages] = struct{}{} + subscribersMu.Unlock() + + defer func() { + subscribersMu.Lock() + delete(subscribers, messages) + subscribersMu.Unlock() + }() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + fmt.Fprint(w, ": connected\n\n") + flusher.Flush() + + for { + select { + case message := <-messages: + fmt.Fprintf(w, "data: %s\n\n", message) + flusher.Flush() + case <-r.Context().Done(): + return + } + } + } + + func publish(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "unable to read request body", http.StatusBadRequest) + return + } + + message := strings.TrimSpace(string(body)) + if message == "" { + http.Error(w, "notification must not be empty", http.StatusBadRequest) + return + } + + message = strings.NewReplacer("\r", " ", "\n", " ").Replace(message) + delivered := broadcast(message) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, "{\"delivered\":%d}\n", delivered) + } + + func broadcast(message string) int { + subscribersMu.Lock() + defer subscribersMu.Unlock() + + delivered := 0 + for subscriber := range subscribers { + select { + case subscriber <- message: + delivered++ + default: + // Do not let a slow subscriber block every other client. + } + } + + return delivered + } + ``` + +=== "Python" + + `handler.py`: + + ```python + import json + import queue + import threading + + from flask import Response, request + + + subscribers = set() + subscribers_lock = threading.Lock() + HEARTBEAT_INTERVAL = 15 + + + def handle(req): + if request.method == "GET": + return subscribe() + if request.method == "POST": + return publish(req) + + return "method not allowed", 405, {"Allow": "GET, POST"} + + + def subscribe(): + messages = queue.Queue(maxsize=1) + with subscribers_lock: + subscribers.add(messages) + + def stream(): + try: + yield ": connected\n\n" + while True: + try: + message = messages.get(timeout=HEARTBEAT_INTERVAL) + yield f"data: {message}\n\n" + except queue.Empty: + # Periodic writes let the server detect idle disconnects. + yield ": keep-alive\n\n" + finally: + with subscribers_lock: + subscribers.discard(messages) + + return Response( + stream(), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache"}, + ) + + + def publish(req): + body = req.decode() if isinstance(req, bytes) else str(req) + message = " ".join(body.splitlines()).strip() + if not message: + return "notification must not be empty", 400 + + delivered = broadcast(message) + return ( + json.dumps({"delivered": delivered}) + "\n", + 200, + {"Content-Type": "application/json"}, + ) + + + def broadcast(message): + delivered = 0 + with subscribers_lock: + for subscriber in subscribers: + try: + subscriber.put_nowait(message) + delivered += 1 + except queue.Full: + # Do not let a slow subscriber block every other client. + pass + + return delivered + ``` + +### Stack file + +=== "Go" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + notification-hub: + lang: golang-middleware + handler: ./notification-hub + image: ttl.sh/openfaas-examples/notification-hub:latest + labels: + com.openfaas.scale.min: "1" + com.openfaas.scale.max: "1" + environment: + exec_timeout: "1h" + read_timeout: "1h1s" + write_timeout: "1h1s" + ``` + +=== "Python" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + notification-hub: + lang: python3-flask + handler: ./notification-hub + image: ttl.sh/openfaas-examples/python-notification-hub:latest + build_args: + TEST_ENABLED: "true" + labels: + com.openfaas.scale.min: "1" + com.openfaas.scale.max: "1" + environment: + exec_timeout: "1h" + read_timeout: "1h1s" + write_timeout: "1h1s" + ``` + +Setting both replica limits to one disables horizontal scaling and makes the +function a singleton. With multiple replicas, a notification would only reach +subscribers connected to the replica that received it. See +[autoscaling](/architecture/autoscaling/) for more detail. + +Do not set `max_inflight` to one for this function. It limits how many requests +a replica can process concurrently. Each SSE subscription remains in flight, +so a limit of one would prevent publishers from connecting. See +[concurrent request limits](/architecture/invocations/#how-many-times-can-a-function-be-invoked) +for more information on limiting requests. + +## Deploy and invoke + +Build and deploy the function: + +```bash +faas-cli up --tag=sha +``` + +Use `faas-cli list -v` to confirm that the function has one configured and +available replica: + +```bash +faas-cli list -v +``` + +Open an SSE subscription in one terminal. The `Accept` header tells the gateway +to stream the response: + +```bash +curl -N -H "Accept: text/event-stream" \ + http://127.0.0.1:8080/function/notification-hub +``` + +Publish a notification from another terminal: + +```bash +curl -s -d "deployment complete" \ + http://127.0.0.1:8080/function/notification-hub | jq +``` + +The publisher reports how many subscribers accepted the notification: + +```json +{ + "delivered": 1 +} +``` + +The subscriber receives: + +```text +data: deployment complete +``` + +## Lifecycle and production considerations + +Fixing the desired replica count at one does not make that replica durable. The +subscriber map exists for the lifetime of the function process. A restart, +reschedule, or deployment creates a new process with an empty map, closes the +existing connections, and requires clients to reconnect. + +WebSockets have the same connection-local state concern and add bidirectional +communication. See +[How to Integrate WebSockets with Serverless Functions and OpenFaaS](https://www.openfaas.com/blog/serverless-websockets/). + +Long-lived connections also require suitable function, gateway, ingress, and +load-balancer timeouts. See [extended timeouts](/tutorials/expanded-timeouts/). + +This example uses an in-memory subscriber map to demonstrate the singleton +pattern. For a real notification implementation, consider using a message bus +to distribute notifications across replicas, and a database or durable message +bus when clients need history or guaranteed delivery. diff --git a/docs/languages/python/index.md b/docs/languages/python/index.md index 1c42297d..e87da0d8 100644 --- a/docs/languages/python/index.md +++ b/docs/languages/python/index.md @@ -484,6 +484,9 @@ def handle(event, context): ## Examples +* [The director pattern](/languages/patterns/director/) +* [The fan-out pattern](/languages/patterns/fan-out/) +* [The singleton pattern](/languages/patterns/singleton/) * [Deploy a function via the OpenFaaS API](examples/openfaas-api.md) * [Access AWS S3 with boto3](examples/s3-boto3.md) * [Call the OpenAI Chat API](examples/openai.md) diff --git a/mkdocs.yml b/mkdocs.yml index 04776b8d..9158b60b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,7 @@ theme: tabs: false features: - content.code.copy + - content.tabs.link palette: primary: blue accent: indigo @@ -132,6 +133,11 @@ nav: - Shell auto completion: ./cli/completion.md - Languages: - Overview: ./languages/overview.md + - Function patterns: + - Overview: ./languages/patterns/index.md + - Director: ./languages/patterns/director.md + - Fan-out: ./languages/patterns/fan-out.md + - Singleton: ./languages/patterns/singleton.md - Python: - Overview: ./languages/python/index.md - Examples: @@ -146,7 +152,7 @@ nav: - Stream Server-Sent Events (SSE): ./languages/python/examples/sse.md - Readiness checks for slow start-up: ./languages/python/examples/readiness.md - Node: ./languages/node.md - - Go: ./languages/go.md + - Go: ./languages/go/index.md - C#: ./languages/csharp.md - PHP: ./languages/php.md - Dockerfile: ./languages/dockerfile.md From 52b54c0c0eb4f2e0f1200ff70fc9303f4e86aade Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 3 Sep 2026 11:42:22 +0200 Subject: [PATCH 2/5] Simplify director pattern example Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- docs/languages/patterns/director.md | 1254 +++++++-------------------- docs/languages/patterns/index.md | 17 +- 2 files changed, 311 insertions(+), 960 deletions(-) diff --git a/docs/languages/patterns/director.md b/docs/languages/patterns/director.md index 1788718c..b24be59b 100644 --- a/docs/languages/patterns/director.md +++ b/docs/languages/patterns/director.md @@ -1,1143 +1,510 @@ -The [Director pattern](/languages/patterns/#director-pattern) implements a -workflow directly in an OpenFaaS function. The director controls how other -functions are invoked, passes data between them, combines results, and handles -errors. +The [Director pattern](/languages/patterns/#director-pattern) can be used to +implement workflow functions that coordinate other functions through +sequencing, branching, or parallel execution. -Use-cases: - -* Exposing a single endpoint for an operation that involves several functions -* Hiding the coordination between functions from the caller -* Coordinating functions that are developed, deployed, or scaled independently -* Keeping workflow decisions, error handling, and the final response in one - place - -This page implements the pattern as a telemetry workflow. The -`telemetry-workflow` function validates a sensor reading, runs temperature and -battery checks in parallel, and returns an `ok` or `alert` result: +This page builds a deliberately small example: two functions each return a +JSON object, and a director combines them into one response. ```text [ Client ] - │ - ▼ POST /function/telemetry-workflow + │ + ▼ ┌──────────────────────┐ -│ telemetry-workflow │ ◄── director: owns the workflow and handles errors +│ director │ └──────────┬───────────┘ + ├── 1. invoke ──► [ function-a ] ──► {"a": 1} ──┐ + ├── 2. invoke ──► [ function-b ] ──► {"b": 2} ──┤ + │ │ + ├────────────── merge results ◄─────────────────┘ │ - ├── 1. Invoke ──► [ validate-reading ] - │◄──── validated reading ────────────┘ - │ - ├── 2. Invoke in parallel - │ ├──► [ temperature-check ] ──┐ - │ └──► [ battery-check ] ──────┤ - │◄──── check results ────────────────┘ - │ - └── 3. Combine results and return ok or alert + ▼ +[ Response: {"a": 1, "b": 2} ] ``` -The workflow demonstrates three common Director operations: +This simple workflow highlights several properties of the Director pattern: -* **Sequence:** validation completes before any checks are started. -* **Parallel execution:** the temperature and battery checks run concurrently. -* **Conditional result:** the director returns `alert` when either check raises - an alert, otherwise it returns `ok`. +* **Single endpoint:** the caller only invokes `director` and receives one + response. +* **Sequencing:** the director invokes `function-a`, followed by `function-b`. +* **Independent functions:** each function is deployed and scaled separately, + and could be written in a different language. +* **Composition and error handling:** the director validates and merges both + results, or returns an error when something fails. ## Create the functions -Choose a language and scaffold the four functions in a single `stack.yaml` -file: +Choose a language and scaffold all three functions in one `stack.yaml` file: === "Go" ```bash faas-cli template store pull golang-middleware - - faas-cli new --lang golang-middleware telemetry-workflow \ + faas-cli new --lang golang-middleware director \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang golang-middleware validate-reading \ + faas-cli new --lang golang-middleware function-a \ --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang golang-middleware temperature-check \ - --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang golang-middleware battery-check \ + faas-cli new --lang golang-middleware function-b \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files and `stack.yaml` with the Go files from - the implementation section below. - === "Python" ```bash faas-cli template store pull python3-http - - faas-cli new --lang python3-http telemetry-workflow \ + faas-cli new --lang python3-http director \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang python3-http validate-reading \ + faas-cli new --lang python3-http function-a \ --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang python3-http temperature-check \ - --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang python3-http battery-check \ + faas-cli new --lang python3-http function-b \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files, `telemetry-workflow/requirements.txt`, - and `stack.yaml` with the Python files from the implementation section - below. - === "Node.js" ```bash faas-cli template store pull node24 - - faas-cli new --lang node24 telemetry-workflow \ + faas-cli new --lang node24 director \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang node24 validate-reading \ + faas-cli new --lang node24 function-a \ --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang node24 temperature-check \ - --append stack.yaml --prefix ttl.sh/openfaas-examples - - faas-cli new --lang node24 battery-check \ + faas-cli new --lang node24 function-b \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files and `stack.yaml` with the Node.js files - from the implementation section below. - -The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the +The example uses the public [ttl.sh] registry. Replace the prefix with your own registry for production use. -## Implement the workflow +The full source code and `stack.yaml` files are available on GitHub for +[Go](https://github.com/openfaas/function-patterns/tree/master/go/director), +[Python](https://github.com/openfaas/function-patterns/tree/master/python/director), +and [Node.js](https://github.com/openfaas/function-patterns/tree/master/node/director). + +## Implement the two functions -### Director: telemetry-workflow +Create two simple functions that each return a JSON payload. Since neither +function needs input, the completed workflow is invoked with a `GET` request. === "Go" - `telemetry-workflow/handler.go`: + `function-a/handler.go`: ```go package function import ( - "bytes" "context" "encoding/json" - "fmt" - "io" "net/http" - "os" - "strings" - "time" ) - const defaultStageTimeout = 5 * time.Second - - type Reading struct { - DeviceID string `json:"device_id"` - TemperatureC float64 `json:"temperature_c"` - BatteryPercent int `json:"battery_percent"` - } - - type TemperatureResult struct { - ValueC float64 `json:"value_c"` - ThresholdC float64 `json:"threshold_c"` - Alert bool `json:"alert"` - } - - type BatteryResult struct { - ValuePercent int `json:"value_percent"` - ThresholdPercent int `json:"threshold_percent"` - Alert bool `json:"alert"` - } - - type Response struct { - DeviceID string `json:"device_id"` - Status string `json:"status"` - Temperature TemperatureResult `json:"temperature"` - Battery BatteryResult `json:"battery"` - DurationMs int64 `json:"duration_ms"` - } - - type callResult struct { - function string - body []byte - status int - err error - } - func Handle(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - input, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "unable to read request body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - timeout, err := configuredStageTimeout() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - gateway := os.Getenv("gateway_url") - if gateway == "" { - gateway = "http://gateway.openfaas:8080" - } - - client := &http.Client{Timeout: timeout} - - validated, status, err := invoke( - r.Context(), client, gateway, "validate-reading", input, - ) - if err != nil { - message := fmt.Sprintf("failed to invoke validate-reading: %s", err) - http.Error(w, message, http.StatusBadGateway) - return - } - if status != http.StatusOK { - message := fmt.Sprintf("validate-reading failed: %s", validated) - http.Error(w, message, status) - return - } - - var reading Reading - if err := json.Unmarshal(validated, &reading); err != nil { - message := fmt.Sprintf( - "unexpected response from validate-reading: %s", - err, - ) - http.Error(w, message, http.StatusBadGateway) - return - } - - functions := []string{"temperature-check", "battery-check"} - results := make(chan callResult, len(functions)) - - for _, function := range functions { - go func(name string) { - body, status, err := invoke( - r.Context(), client, gateway, name, validated, - ) - results <- callResult{ - function: name, - body: body, - status: status, - err: err, - } - }(function) - } - - completed := make(map[string]callResult, len(functions)) - for range functions { - result := <-results - completed[result.function] = result - } - - for _, function := range functions { - result := completed[function] - if result.err != nil { - message := fmt.Sprintf( - "failed to invoke %s: %s", - function, - result.err, - ) - http.Error(w, message, http.StatusBadGateway) - return - } - if result.status != http.StatusOK { - message := fmt.Sprintf("%s failed: %s", function, result.body) - http.Error(w, message, result.status) - return - } - } - - var temperature TemperatureResult - if err := json.Unmarshal( - completed["temperature-check"].body, - &temperature, - ); err != nil { - message := fmt.Sprintf( - "unexpected response from temperature-check: %s", - err, - ) - http.Error(w, message, http.StatusBadGateway) - return - } - - var battery BatteryResult - if err := json.Unmarshal( - completed["battery-check"].body, - &battery, - ); err != nil { - message := fmt.Sprintf( - "unexpected response from battery-check: %s", - err, - ) - http.Error(w, message, http.StatusBadGateway) - return - } - - workflowStatus := "ok" - if temperature.Alert || battery.Alert { - workflowStatus = "alert" - } - - response := Response{ - DeviceID: reading.DeviceID, - Status: workflowStatus, - Temperature: temperature, - Battery: battery, - DurationMs: time.Since(start).Milliseconds(), - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - } - - func configuredStageTimeout() (time.Duration, error) { - value := os.Getenv("stage_timeout") - if value == "" { - return defaultStageTimeout, nil - } - - timeout, err := time.ParseDuration(value) - if err != nil || timeout <= 0 { - return 0, fmt.Errorf("invalid stage_timeout %q", value) - } - - return timeout, nil + json.NewEncoder(w).Encode(map[string]int{"a": 1}) } + ``` - func invoke( - ctx context.Context, - client *http.Client, - gateway string, - function string, - body []byte, - ) ([]byte, int, error) { - url := strings.TrimRight(gateway, "/") + "/function/" + function - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - url, - bytes.NewReader(body), - ) - if err != nil { - return nil, 0, fmt.Errorf("create request for %s: %w", function, err) - } - req.Header.Set("Content-Type", "application/json") + `function-b/handler.go`: - res, err := client.Do(req) - if err != nil { - return nil, 0, fmt.Errorf("invoke %s: %w", function, err) - } - defer res.Body.Close() + ```go + package function - out, err := io.ReadAll(res.Body) - if err != nil { - return nil, 0, fmt.Errorf("read response from %s: %w", function, err) - } + import ( + "encoding/json" + "net/http" + ) - return out, res.StatusCode, nil + func Handle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"b": 2}) } ``` === "Python" - `telemetry-workflow/handler.py`: + `function-a/handler.py`: ```python - import concurrent.futures - import os - import time - - import requests - - - GATEWAY_URL = os.getenv( - "gateway_url", "http://gateway.openfaas:8080" - ).rstrip("/") - STAGE_TIMEOUT = float(os.getenv("stage_timeout", "5")) - if STAGE_TIMEOUT <= 0: - raise ValueError("stage_timeout must be greater than zero") - - def handle(event, context): - started = time.monotonic() - body = event.body - - try: - validated = invoke("validate-reading", body) - except requests.RequestException as err: - return error(502, f"failed to invoke validate-reading: {err}") - - if validated.status_code != 200: - return error( - validated.status_code, - f"validate-reading failed: {validated.text}", - ) - - try: - reading = validated.json() - except ValueError as err: - return error(502, f"unexpected response from validate-reading: {err}") - - functions = ("temperature-check", "battery-check") - completed = {} - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: - futures = { - name: executor.submit(invoke, name, validated.content) - for name in functions - } - for name, future in futures.items(): - try: - completed[name] = future.result() - except requests.RequestException as err: - return error(502, f"failed to invoke {name}: {err}") - - for name in functions: - response = completed[name] - if response.status_code != 200: - return error( - response.status_code, - f"{name} failed: {response.text}", - ) - - try: - temperature = completed["temperature-check"].json() - battery = completed["battery-check"].json() - except ValueError as err: - return error(502, f"unexpected response from check function: {err}") - - has_alert = temperature["alert"] or battery["alert"] - workflow_status = "alert" if has_alert else "ok" - return { - "statusCode": 200, - "body": { - "device_id": reading["device_id"], - "status": workflow_status, - "temperature": temperature, - "battery": battery, - "duration_ms": int((time.monotonic() - started) * 1000), - }, - } - - - def invoke(function, body): - return requests.post( - f"{GATEWAY_URL}/function/{function}", - data=body, - headers={"Content-Type": "application/json"}, - timeout=STAGE_TIMEOUT, - ) - - - def error(status_code, message): - return {"statusCode": status_code, "body": message.strip()} + return {"statusCode": 200, "body": {"a": 1}} ``` - `telemetry-workflow/requirements.txt`: + `function-b/handler.py`: - ```text - requests + ```python + def handle(event, context): + return {"statusCode": 200, "body": {"b": 2}} ``` === "Node.js" - `telemetry-workflow/handler.js`: + `function-a/handler.js`: ```javascript 'use strict' - const { performance } = require('node:perf_hooks') - - const gatewayURL = process.env.gateway_url || - 'http://gateway.openfaas:8080' - const stageTimeout = configuredTimeout( - process.env.stage_timeout || '5', - 'stage_timeout' - ) - - module.exports = async (event, context) => { - const started = performance.now() - const input = requestBody(event.body) - - let validated - try { - validated = await invoke('validate-reading', input) - } catch (error) { - return fail( - context, - 502, - `failed to invoke validate-reading: ${error.message}` - ) - } - - if (validated.status !== 200) { - return fail( - context, - validated.status, - `validate-reading failed: ${await validated.text()}` - ) - } - - let reading - try { - reading = await validated.json() - } catch (error) { - return fail( - context, - 502, - `unexpected response from validate-reading: ${error.message}` - ) - } - - const body = JSON.stringify(reading) - const names = ['temperature-check', 'battery-check'] - let responses - try { - responses = await Promise.all( - names.map(async (name) => [name, await invoke(name, body)]) - ) - } catch (error) { - return fail(context, 502, `failed to invoke check: ${error.message}`) - } - - const completed = Object.fromEntries(responses) - for (const name of names) { - const response = completed[name] - if (response.status !== 200) { - return fail( - context, - response.status, - `${name} failed: ${await response.text()}` - ) - } - } - - let temperature - let battery - try { - temperature = await completed['temperature-check'].json() - battery = await completed['battery-check'].json() - } catch (error) { - return fail( - context, - 502, - `unexpected response from check function: ${error.message}` - ) - } - - const status = temperature.alert || battery.alert ? 'alert' : 'ok' - return context - .status(200) - .headers({ 'Content-Type': 'application/json' }) - .succeed({ - device_id: reading.device_id, - status, - temperature, - battery, - duration_ms: Math.round(performance.now() - started) - }) - } - - function invoke (name, body) { - const gateway = gatewayURL.replace(/\/$/, '') - return fetch(`${gateway}/function/${name}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - signal: AbortSignal.timeout(stageTimeout) - }) - } + module.exports = async (event, context) => context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ a: 1 }) + ``` - function requestBody (body) { - if (Buffer.isBuffer(body)) { - return body - } - return typeof body === 'string' ? body : JSON.stringify(body) - } + `function-b/handler.js`: - function configuredTimeout (value, name) { - const seconds = Number(value) - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error(`${name} must be greater than zero`) - } - return seconds * 1000 - } + ```javascript + 'use strict' - function fail (context, status, message) { - return context - .status(status) - .headers({ 'Content-Type': 'text/plain' }) - .succeed(message.trim()) - } + module.exports = async (event, context) => context + .status(200) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ b: 2 }) ``` -The director invokes every stage through the gateway, so each stage can be -written in a different language and scaled independently. A stage transport -error returns `502 Bad Gateway`. A non-200 response is attributed to the stage -that returned it and passed through to the caller. - -The two checks are started concurrently, and the director waits for both -before choosing the final workflow status. - -### Stage: validate-reading +## Implement the director -The validation stage normalizes the device ID and rejects invalid values before -the parallel checks consume capacity. +The director invokes `function-a` and `function-b` in sequence. It decodes each +response as a JSON object and copies its fields into the combined response. === "Go" - `validate-reading/handler.go`: + `director/handler.go`: ```go package function import ( + "context" "encoding/json" + "fmt" "net/http" + "os" "strings" + "time" ) - type Reading struct { - DeviceID string `json:"device_id"` - TemperatureC float64 `json:"temperature_c"` - BatteryPercent int `json:"battery_percent"` - } + const defaultStageTimeout = 5 * time.Second func Handle(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - var reading Reading - if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { - http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) + gateway := os.Getenv("gateway_url") + if gateway == "" { + gateway = "http://gateway.openfaas:8080" + } + gateway = strings.TrimRight(gateway, "/") + + timeout := stageTimeout() + + // Invoke function-a and decode its JSON response. + ctxA, cancelA := context.WithTimeout(r.Context(), timeout) + defer cancelA() + reqA, err := http.NewRequestWithContext( + ctxA, + http.MethodGet, + gateway+"/function/function-a", + nil, + ) + if err != nil { + http.Error(w, fmt.Sprintf("function-a: %s", err), http.StatusBadGateway) return } - reading.DeviceID = strings.TrimSpace(reading.DeviceID) - if reading.DeviceID == "" { - http.Error(w, "device_id is required", http.StatusBadRequest) + resA, err := http.DefaultClient.Do(reqA) + if err != nil { + http.Error(w, fmt.Sprintf("function-a: %s", err), http.StatusBadGateway) return } - if reading.TemperatureC < -100 || reading.TemperatureC > 200 { - http.Error( - w, - "temperature_c must be between -100 and 200", - http.StatusBadRequest, - ) + defer resA.Body.Close() + if resA.StatusCode != http.StatusOK { + status := resA.StatusCode + http.Error(w, fmt.Sprintf("function-a: returned %s", resA.Status), status) return } - if reading.BatteryPercent < 0 || reading.BatteryPercent > 100 { + + combined := map[string]any{} + if err := json.NewDecoder(resA.Body).Decode(&combined); err != nil { http.Error( w, - "battery_percent must be between 0 and 100", - http.StatusBadRequest, + fmt.Sprintf("function-a: returned invalid JSON: %s", err), + http.StatusBadGateway, ) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(reading) - } - ``` - -=== "Python" - - `validate-reading/handler.py`: - - ```python - import json - - - def handle(event, context): - try: - reading = json.loads(event.body) - except (TypeError, ValueError): - return error("expected a JSON sensor reading") - - device_id = str(reading.get("device_id", "")).strip() - temperature = reading.get("temperature_c", 0) - battery = reading.get("battery_percent", 0) - - if not device_id: - return error("device_id is required") - if not is_number(temperature) or temperature < -100 or temperature > 200: - return error("temperature_c must be between -100 and 200") - if ( - not isinstance(battery, int) - or isinstance(battery, bool) - or battery < 0 - or battery > 100 - ): - return error("battery_percent must be between 0 and 100") - - return { - "statusCode": 200, - "body": { - "device_id": device_id, - "temperature_c": temperature, - "battery_percent": battery, - }, + // Invoke function-b after function-a has completed. + ctxB, cancelB := context.WithTimeout(r.Context(), timeout) + defer cancelB() + reqB, err := http.NewRequestWithContext( + ctxB, + http.MethodGet, + gateway+"/function/function-b", + nil, + ) + if err != nil { + http.Error(w, fmt.Sprintf("function-b: %s", err), http.StatusBadGateway) + return } + resB, err := http.DefaultClient.Do(reqB) + if err != nil { + http.Error(w, fmt.Sprintf("function-b: %s", err), http.StatusBadGateway) + return + } + defer resB.Body.Close() + if resB.StatusCode != http.StatusOK { + status := resB.StatusCode + http.Error(w, fmt.Sprintf("function-b: returned %s", resB.Status), status) + return + } - def is_number(value): - return isinstance(value, (int, float)) and not isinstance(value, bool) - - - def error(message): - return {"statusCode": 400, "body": message} - ``` - -=== "Node.js" - - `validate-reading/handler.js`: - - ```javascript - 'use strict' - - module.exports = async (event, context) => { - let reading - try { - reading = parseBody(event.body) - } catch (error) { - return fail(context, 'expected a JSON sensor reading') - } - - const deviceID = String(reading.device_id || '').trim() - const temperature = reading.temperature_c ?? 0 - const battery = reading.battery_percent ?? 0 - - if (!deviceID) { - return fail(context, 'device_id is required') - } - if ( - !Number.isFinite(temperature) || - temperature < -100 || - temperature > 200 - ) { - return fail( - context, - 'temperature_c must be between -100 and 200' - ) - } - if (!Number.isInteger(battery) || battery < 0 || battery > 100) { - return fail( - context, - 'battery_percent must be between 0 and 100' - ) - } + functionB := map[string]any{} + if err := json.NewDecoder(resB.Body).Decode(&functionB); err != nil { + http.Error( + w, + fmt.Sprintf("function-b: returned invalid JSON: %s", err), + http.StatusBadGateway, + ) + return + } - return context - .status(200) - .headers({ 'Content-Type': 'application/json' }) - .succeed({ - device_id: deviceID, - temperature_c: temperature, - battery_percent: battery - }) - } + // Merge function-b into function-a and return the combined object. + for key, value := range functionB { + combined[key] = value + } - function parseBody (body) { - if (Buffer.isBuffer(body)) { - return JSON.parse(body.toString()) - } - return typeof body === 'string' ? JSON.parse(body) : body + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(combined) } - function fail (context, message) { - return context - .status(400) - .headers({ 'Content-Type': 'text/plain' }) - .succeed(message) + func stageTimeout() time.Duration { + if value := os.Getenv("stage_timeout"); value != "" { + if timeout, err := time.ParseDuration(value); err == nil && timeout > 0 { + return timeout + } + } + return defaultStageTimeout } ``` -### Parallel stages: temperature-check and battery-check - -The checks are intentionally small. In a real workflow they could call a model, -query device metadata, or apply rules maintained by another team. +=== "Python" -**Temperature check** + `director/handler.py`: -=== "Go" + ```python + import os - `temperature-check/handler.go`: + import requests - ```go - package function - import ( - "encoding/json" - "net/http" - ) + GATEWAY_URL = os.getenv( + "gateway_url", "http://gateway.openfaas:8080" + ).rstrip("/") + STAGE_TIMEOUT = float(os.getenv("stage_timeout", "5")) - const thresholdC = 75.0 - type Reading struct { - TemperatureC float64 `json:"temperature_c"` - } + def handle(event, context): + # Invoke function-a and decode its JSON response. + try: + response_a = requests.get( + f"{GATEWAY_URL}/function/function-a", + timeout=STAGE_TIMEOUT, + ) + except requests.RequestException as err: + return error(502, f"function-a: {err}") - type Response struct { - ValueC float64 `json:"value_c"` - ThresholdC float64 `json:"threshold_c"` - Alert bool `json:"alert"` - } + if response_a.status_code != 200: + return error(response_a.status_code, f"function-a: {response_a.text}") - func Handle(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() + try: + combined = response_a.json() + except ValueError as err: + return error(502, f"function-a returned invalid JSON: {err}") - var reading Reading - if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { - http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) - return - } + if not isinstance(combined, dict): + return error(502, "function-a did not return a JSON object") - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(Response{ - ValueC: reading.TemperatureC, - ThresholdC: thresholdC, - Alert: reading.TemperatureC > thresholdC, - }) - } - ``` + # Invoke function-b after function-a has completed. + try: + response_b = requests.get( + f"{GATEWAY_URL}/function/function-b", + timeout=STAGE_TIMEOUT, + ) + except requests.RequestException as err: + return error(502, f"function-b: {err}") -=== "Python" + if response_b.status_code != 200: + return error(response_b.status_code, f"function-b: {response_b.text}") - `temperature-check/handler.py`: + try: + function_b = response_b.json() + except ValueError as err: + return error(502, f"function-b returned invalid JSON: {err}") - ```python - import json + if not isinstance(function_b, dict): + return error(502, "function-b did not return a JSON object") + # Merge function-b into function-a and return the combined object. + combined.update(function_b) - THRESHOLD_C = 75.0 + return {"statusCode": 200, "body": combined} - def handle(event, context): - try: - reading = json.loads(event.body) - value = reading["temperature_c"] - except (KeyError, TypeError, ValueError): - return {"statusCode": 400, "body": "expected a JSON sensor reading"} - - return { - "statusCode": 200, - "body": { - "value_c": value, - "threshold_c": THRESHOLD_C, - "alert": value > THRESHOLD_C, - }, - } + def error(status_code, message): + return {"statusCode": status_code, "body": message.strip()} ``` + Add `requests` to `director/requirements.txt`. + === "Node.js" - `temperature-check/handler.js`: + `director/handler.js`: ```javascript 'use strict' - const thresholdC = 75.0 + const gatewayURL = process.env.gateway_url || + 'http://gateway.openfaas:8080' + const stageTimeout = Number(process.env.stage_timeout || '5') * 1000 module.exports = async (event, context) => { - let reading + const gateway = gatewayURL.replace(/\/$/, '') + + // Invoke function-a and decode its JSON response. + let responseA try { - reading = parseBody(event.body) + responseA = await fetch(`${gateway}/function/function-a`, { + method: 'GET', + signal: AbortSignal.timeout(stageTimeout) + }) } catch (error) { - return fail(context) + return fail(context, 502, `function-a: ${error.message}`) } - const value = reading.temperature_c - if (!Number.isFinite(value)) { - return fail(context) + if (responseA.status !== 200) { + return fail(context, responseA.status, `function-a: ${await responseA.text()}`) } - return context - .status(200) - .headers({ 'Content-Type': 'application/json' }) - .succeed({ - value_c: value, - threshold_c: thresholdC, - alert: value > thresholdC - }) - } - - function parseBody (body) { - if (Buffer.isBuffer(body)) { - return JSON.parse(body.toString()) + let combined + try { + combined = await responseA.json() + } catch (error) { + return fail(context, 502, `function-a returned invalid JSON: ${error.message}`) } - return typeof body === 'string' ? JSON.parse(body) : body - } - - function fail (context) { - return context - .status(400) - .headers({ 'Content-Type': 'text/plain' }) - .succeed('expected a JSON sensor reading') - } - ``` -**Battery check** - -=== "Go" - - `battery-check/handler.go`: - - ```go - package function - - import ( - "encoding/json" - "net/http" - ) - - const thresholdPercent = 20 - - type Reading struct { - BatteryPercent int `json:"battery_percent"` - } - - type Response struct { - ValuePercent int `json:"value_percent"` - ThresholdPercent int `json:"threshold_percent"` - Alert bool `json:"alert"` - } - - func Handle(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - var reading Reading - if err := json.NewDecoder(r.Body).Decode(&reading); err != nil { - http.Error(w, "expected a JSON sensor reading", http.StatusBadRequest) - return - } + if (!combined || Array.isArray(combined) || typeof combined !== 'object') { + return fail(context, 502, 'function-a did not return a JSON object') + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(Response{ - ValuePercent: reading.BatteryPercent, - ThresholdPercent: thresholdPercent, - Alert: reading.BatteryPercent < thresholdPercent, + // Invoke function-b after function-a has completed. + let responseB + try { + responseB = await fetch(`${gateway}/function/function-b`, { + method: 'GET', + signal: AbortSignal.timeout(stageTimeout) }) - } - ``` - -=== "Python" - - `battery-check/handler.py`: - - ```python - import json - - - THRESHOLD_PERCENT = 20 - - - def handle(event, context): - try: - reading = json.loads(event.body) - value = reading["battery_percent"] - except (KeyError, TypeError, ValueError): - return {"statusCode": 400, "body": "expected a JSON sensor reading"} - - return { - "statusCode": 200, - "body": { - "value_percent": value, - "threshold_percent": THRESHOLD_PERCENT, - "alert": value < THRESHOLD_PERCENT, - }, - } - ``` - -=== "Node.js" - - `battery-check/handler.js`: - - ```javascript - 'use strict' + } catch (error) { + return fail(context, 502, `function-b: ${error.message}`) + } - const thresholdPercent = 20 + if (responseB.status !== 200) { + return fail(context, responseB.status, `function-b: ${await responseB.text()}`) + } - module.exports = async (event, context) => { - let reading + let functionB try { - reading = parseBody(event.body) + functionB = await responseB.json() } catch (error) { - return fail(context) + return fail(context, 502, `function-b returned invalid JSON: ${error.message}`) } - const value = reading.battery_percent - if (!Number.isInteger(value)) { - return fail(context) + if (!functionB || Array.isArray(functionB) || typeof functionB !== 'object') { + return fail(context, 502, 'function-b did not return a JSON object') } + // Merge function-b into function-a and return the combined object. + Object.assign(combined, functionB) + return context .status(200) .headers({ 'Content-Type': 'application/json' }) - .succeed({ - value_percent: value, - threshold_percent: thresholdPercent, - alert: value < thresholdPercent - }) + .succeed(combined) } - function parseBody (body) { - if (Buffer.isBuffer(body)) { - return JSON.parse(body.toString()) - } - return typeof body === 'string' ? JSON.parse(body) : body - } - - function fail (context) { + function fail (context, status, message) { return context - .status(400) + .status(status) .headers({ 'Content-Type': 'text/plain' }) - .succeed('expected a JSON sensor reading') + .succeed(message.trim()) } ``` -### Stack file +The director calls each function through the gateway, so the functions can be +written in different languages and scaled independently. A transport error or +invalid JSON response returns `502 Bad Gateway`. A non-200 response is +attributed to the function that returned it and its status is passed through to +the caller. -=== "Go" +Configure the director's timeouts in `stack.yaml`: - `stack.yaml`: +=== "Go" ```yaml - version: 1.0 - provider: - name: openfaas - gateway: http://127.0.0.1:8080 functions: - telemetry-workflow: + director: lang: golang-middleware - handler: ./telemetry-workflow - image: ttl.sh/openfaas-examples/telemetry-workflow:latest + handler: ./director + image: ttl.sh/openfaas-examples/director:latest environment: stage_timeout: 5s exec_timeout: 15s read_timeout: 16s write_timeout: 16s - - validate-reading: - lang: golang-middleware - handler: ./validate-reading - image: ttl.sh/openfaas-examples/validate-reading:latest - - temperature-check: - lang: golang-middleware - handler: ./temperature-check - image: ttl.sh/openfaas-examples/temperature-check:latest - - battery-check: - lang: golang-middleware - handler: ./battery-check - image: ttl.sh/openfaas-examples/battery-check:latest ``` === "Python" - `stack.yaml`: - ```yaml - version: 1.0 - provider: - name: openfaas - gateway: http://127.0.0.1:8080 functions: - telemetry-workflow: + director: lang: python3-http - handler: ./telemetry-workflow - image: ttl.sh/openfaas-examples/python-telemetry-workflow:latest - build_args: - TEST_ENABLED: "true" + handler: ./director + image: ttl.sh/openfaas-examples/director:latest environment: stage_timeout: "5" exec_timeout: 15s read_timeout: 16s write_timeout: 16s - - validate-reading: - lang: python3-http - handler: ./validate-reading - image: ttl.sh/openfaas-examples/python-validate-reading:latest - build_args: - TEST_ENABLED: "true" - - temperature-check: - lang: python3-http - handler: ./temperature-check - image: ttl.sh/openfaas-examples/python-temperature-check:latest - build_args: - TEST_ENABLED: "true" - - battery-check: - lang: python3-http - handler: ./battery-check - image: ttl.sh/openfaas-examples/python-battery-check:latest - build_args: - TEST_ENABLED: "true" ``` === "Node.js" - `stack.yaml`: - ```yaml - version: 1.0 - provider: - name: openfaas - gateway: http://127.0.0.1:8080 functions: - telemetry-workflow: + director: lang: node24 - handler: ./telemetry-workflow - image: ttl.sh/openfaas-examples/node-telemetry-workflow:latest + handler: ./director + image: ttl.sh/openfaas-examples/director:latest environment: stage_timeout: "5" exec_timeout: 15s read_timeout: 16s write_timeout: 16s - - validate-reading: - lang: node24 - handler: ./validate-reading - image: ttl.sh/openfaas-examples/node-validate-reading:latest - - temperature-check: - lang: node24 - handler: ./temperature-check - image: ttl.sh/openfaas-examples/node-temperature-check:latest - - battery-check: - lang: node24 - handler: ./battery-check - image: ttl.sh/openfaas-examples/node-battery-check:latest ``` ## Configure timeouts -A director stays active while it waits for the stages it invokes. This example -uses two kinds of timeout: +A director stays active while it waits for the functions it invokes, so each +call should have a timeout. + +This example uses the custom `stage_timeout` +environment variable, the other variables below are OpenFaaS watchdog settings. | Setting | Scope | |---------|-------| -| `stage_timeout` | Application-level HTTP client timeout for each downstream call | +| `stage_timeout` | Custom HTTP client timeout for each downstream call | | `exec_timeout` | Maximum duration of the complete director invocation | -| `read_timeout`, `write_timeout` | Watchdog HTTP timeouts, set slightly longer than `exec_timeout` | +| `read_timeout`, `write_timeout` | Watchdog timeouts, set slightly longer than `exec_timeout` | -The director calls `validate-reading` first, followed by the two checks in -parallel. Its expected duration is therefore the validation duration plus the -slower of the two checks, with some additional overhead. Configure the -director's timeout for that complete path, not for a single stage. +This director calls the functions sequentially, so its expected duration is +the sum of both calls plus a small amount of overhead. Configure the director's +timeout for that complete path, not for a single function. The gateway's `upstream_timeout` must be at least as long as the director's `exec_timeout`. Adjust the example values for your own functions and see @@ -1146,61 +513,40 @@ configuration. ## Deploy and invoke -Build, push, and deploy all four functions: +Build, push, and deploy all three functions: ```bash faas-cli up --tag=sha ``` -Invoke the director with a reading that exceeds both thresholds: +Invoke the director: ```bash -curl -s http://127.0.0.1:8080/function/telemetry-workflow \ - -H "Content-Type: application/json" \ - -d '{"device_id":"pump-17","temperature_c":82.4,"battery_percent":12}' | \ - jq +curl -s http://127.0.0.1:8080/function/director | jq ``` -The director combines the two check results and selects the `alert` path: +The director returns the union of the two responses: ```json { - "device_id": "pump-17", - "status": "alert", - "temperature": { - "value_c": 82.4, - "threshold_c": 75, - "alert": true - }, - "battery": { - "value_percent": 12, - "threshold_percent": 20, - "alert": true - }, - "duration_ms": 4 + "a": 1, + "b": 2 } ``` -Submit values within both thresholds to select the `ok` path: - -```bash -curl -s http://127.0.0.1:8080/function/telemetry-workflow \ - -H "Content-Type: application/json" \ - -d '{"device_id":"pump-17","temperature_c":48.2,"battery_percent":78}' | \ - jq -``` - ## Workflow considerations -* A validation failure stops the workflow before either parallel check is - invoked. -* Parallel stages should be independent. If one stage needs the result of - another, invoke them in sequence instead. -* The final `ok` or `alert` decision is a small conditional branch. It could be - extended to invoke a notification function for alerts or a storage function - for normal readings. -* For a long-running workflow, invoke the director through - `/async-function/telemetry-workflow` with an `X-Callback-Url`. The director - continues to wait for its stages, while the client receives the final result - through the callback. See - [asynchronous functions](/reference/async/#how-it-works). +* Invoke dependent functions in sequence. Independent functions can run in + parallel to reduce latency, but the director must still wait for every result + it needs before continuing. +* The director owns the error policy. Depending on the workflow, it can stop, + retry, return a partial result, or save progress for a later invocation. +* If retrying the director could repeat side effects in a function that already + completed, make those operations idempotent. +* The functions invoked by a director are deployed independently. Each can use + a different language, scale separately, and be updated without moving the + workflow logic out of the director. +* For a long-running workflow, the director can be invoke asynchronously through + `/async-function/director` with an `X-Callback-Url`. The director continues to + wait for its functions, while the client receives the final result through + the callback. See [asynchronous functions](/reference/async/#how-it-works). diff --git a/docs/languages/patterns/index.md b/docs/languages/patterns/index.md index 9c16679c..131a368d 100644 --- a/docs/languages/patterns/index.md +++ b/docs/languages/patterns/index.md @@ -19,9 +19,9 @@ A director is an OpenFaaS function that coordinates a workflow. It invokes other functions through the gateway, passes results between them, decides what runs next, and returns or stores the final result. -The functions invoked by the director are deployed independently, so each can -use a different language, scale separately, and be updated without moving the -workflow logic out of the director. +A director can invoke functions in sequence, choose the next function from an +earlier result, or run independent functions in parallel. It owns the workflow +and presents a single endpoint to the caller. ```text [ Client ] @@ -49,10 +49,15 @@ workflow logic out of the director. [ Return response / persist result ] ``` +The functions invoked by the director are deployed independently, so each can +use a different language, scale separately, and be updated without moving the +workflow logic out of the director + **Useful when:** -* Several independently deployed functions form one logical operation. -* The caller needs one endpoint and one combined response. +* Exposing a single endpoint for an operation that involves several functions +* Hiding the coordination between functions from the caller +* Coordinating functions that are developed, deployed, or scaled independently * A workflow needs sequencing, branching, or independent functions to run in parallel. @@ -168,7 +173,7 @@ results can be fanned back in. One way to implement this is to: results, send a notification, or start the next step in the workflow. When results do not need to be combined, each callback can be handled -independently. +independently. See [fan-in](/languages/patterns/fan-in/) for the implementation. **Examples:** From a8512ae16999e5831e50ea05b6f3b752c2273b4f Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 3 Sep 2026 13:15:19 +0200 Subject: [PATCH 3/5] Simplify singleton pattern example Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- docs/languages/patterns/index.md | 12 +- docs/languages/patterns/singleton.md | 441 ++++++++++++--------------- 2 files changed, 197 insertions(+), 256 deletions(-) diff --git a/docs/languages/patterns/index.md b/docs/languages/patterns/index.md index 131a368d..617ab259 100644 --- a/docs/languages/patterns/index.md +++ b/docs/languages/patterns/index.md @@ -191,7 +191,7 @@ run concurrently, or must limit access to an external resource. │ ▼ HTTP / invoke ┌──────────────────────────┐ -│ Singleton Function │ ◄── Fixed at one replica +│ Singleton Function │ │ │ │ [ Replica 1 ] │ └──────────────────────────┘ @@ -210,10 +210,12 @@ functions: **Useful when:** -* Connections or subscribers are stored in the function process. -* Software or an external resource requires a fixed number of function - replicas. -* Horizontal scaling is intentionally undesirable. +* Reusing an expensive process-local resource, such as a database client, + downloaded dataset, or machine-learning model. +* Keeping stateful connections or subscribers in the same process. +* Running software that permits only one active instance. +* Maintaining a single session with an upstream service that limits active + clients. **Design considerations:** diff --git a/docs/languages/patterns/singleton.md b/docs/languages/patterns/singleton.md index 30d674f7..3053b916 100644 --- a/docs/languages/patterns/singleton.md +++ b/docs/languages/patterns/singleton.md @@ -1,29 +1,35 @@ -The [Singleton pattern](/languages/patterns/#singleton-pattern) fixes a -function's desired replica count at one. This example demonstrates the pattern -with a small Server-Sent Events (SSE) notification hub. Singleton functions are -useful when a workload keeps connection-local state or wraps software that -cannot run concurrently. +The [Singleton pattern](/languages/patterns/#singleton-pattern) configures a +function with a desired replica count of one. With one replica, every request +or connection is routed to the same function process, allowing them to share +process-local state and reuse the same resources. -Use-cases: - -* Keeping connection-local state, such as SSE subscribers or WebSocket sessions -* Wrapping software that cannot safely run concurrently -* Limiting access to an external resource that permits one active client - -## How it works - -The `notification-hub` function accepts two kinds of request: +This page uses a deliberately small example: a function performs simulated +expensive setup once when its process starts, then reuses the resulting +resource for every request. ```text -[ Publisher ] ── POST ──► [ notification-hub ] ── SSE ──► [ Subscribers ] - one replica + ┌──────────────────────────┐ +[ Client A ] ──┐ │ Singleton Function │ +[ Client B ] ──┼── connections ────►│ │ +[ Client C ] ──┘ │ [ Replica 1 ] │ + └──────────────────────────┘ ``` -`GET` opens an SSE subscription, while `POST` broadcasts its request body to -all connected subscribers. +This simple function highlights several properties of the Singleton pattern: + +* **One replica:** setting both replica limits to one disables horizontal + scaling, so OpenFaaS does not add replicas as load increases. +* **Reuse:** requests are routed to the same replica, so they can use the same + process-local resource. + +The example uses a two-second delay to simulate expensive setup. In a real +function, the same point in the lifecycle could be used to: -The function runs with one replica, so every subscription is registered in the -same in-memory map and each notification can reach all connected subscribers. +* Create a database connection pool or an SDK client. +* Download and load a large dataset or machine-learning model. +* Open a persistent connection whose state remains in the function process. +* Establish a session with an upstream service that has costly setup or permits + only one active client. ## Create the function @@ -34,27 +40,42 @@ Choose a language and scaffold the function: ```bash faas-cli template store pull golang-middleware - faas-cli new --lang golang-middleware notification-hub \ + faas-cli new --lang golang-middleware singleton \ --prefix ttl.sh/openfaas-examples ``` === "Python" ```bash - faas-cli template store pull python3-flask + faas-cli template store pull python3-http - faas-cli new --lang python3-flask notification-hub \ + faas-cli new --lang python3-http singleton \ --prefix ttl.sh/openfaas-examples ``` -Replace the generated handler and `stack.yaml` with the files from the -implementation section below. +=== "Node.js" + + ```bash + faas-cli template store pull node24 + + faas-cli new --lang node24 singleton \ + --prefix ttl.sh/openfaas-examples + ``` The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the prefix with your own registry for production use. +The full source code and `stack.yaml` files are available on GitHub for +[Go](https://github.com/openfaas/function-patterns/tree/master/go/singleton), +[Python](https://github.com/openfaas/function-patterns/tree/master/python/singleton), +and [Node.js](https://github.com/openfaas/function-patterns/tree/master/node/singleton). + ## Implement the function +Each implementation starts the simulated setup when the function process +starts. The `/ready` path returns `200` only after the resource is available. +Normal requests then reuse the resource. + === "Go" `handler.go`: @@ -64,188 +85,109 @@ prefix with your own registry for production use. import ( "fmt" - "io" "net/http" - "strings" - "sync" + "time" ) - var ( - subscribersMu sync.Mutex - subscribers = make(map[chan string]struct{}) - ) + var expensiveResource string - func Handle(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - subscribe(w, r) - case http.MethodPost: - publish(w, r) - default: - w.Header().Set("Allow", "GET, POST") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } + func init() { + // Initialize once when the function process starts, not on every request. + expensiveResource = setupExpensiveResource() } - func subscribe(w http.ResponseWriter, r *http.Request) { - flusher, ok := w.(http.Flusher) - if !ok { - http.Error( - w, - "streaming is not supported", - http.StatusInternalServerError, - ) + func setupExpensiveResource() string { + // A real function could create a database client, open a persistent + // connection, or download a large dataset here. + time.Sleep(2 * time.Second) + return "The expensive resource is ready" + } + + func Handle(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ready" { + fmt.Fprintln(w, "Ready") return } - messages := make(chan string, 1) - subscribersMu.Lock() - subscribers[messages] = struct{}{} - subscribersMu.Unlock() - - defer func() { - subscribersMu.Lock() - delete(subscribers, messages) - subscribersMu.Unlock() - }() - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - fmt.Fprint(w, ": connected\n\n") - flusher.Flush() - - for { - select { - case message := <-messages: - fmt.Fprintf(w, "data: %s\n\n", message) - flusher.Flush() - case <-r.Context().Done(): - return - } - } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, expensiveResource) } + ``` - func publish(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() +=== "Python" - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "unable to read request body", http.StatusBadRequest) - return - } + `handler.py`: - message := strings.TrimSpace(string(body)) - if message == "" { - http.Error(w, "notification must not be empty", http.StatusBadRequest) - return - } + ```python + import time - message = strings.NewReplacer("\r", " ", "\n", " ").Replace(message) - delivered := broadcast(message) - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, "{\"delivered\":%d}\n", delivered) - } + def setup_expensive_resource(): + # A real function could create a database client, open a persistent + # connection, or download a large dataset here. + time.sleep(2) + return "The expensive resource is ready" - func broadcast(message string) int { - subscribersMu.Lock() - defer subscribersMu.Unlock() - - delivered := 0 - for subscriber := range subscribers { - select { - case subscriber <- message: - delivered++ - default: - // Do not let a slow subscriber block every other client. - } - } - return delivered - } + # Initialize once when the function process starts, not on every request. + expensive_resource = setup_expensive_resource() + + + def handle(event, context): + if event.path == "/ready": + return {"statusCode": 200, "body": "Ready"} + + return {"statusCode": 200, "body": expensive_resource} ``` -=== "Python" +=== "Node.js" - `handler.py`: + `handler.js`: - ```python - import json - import queue - import threading - - from flask import Response, request - - - subscribers = set() - subscribers_lock = threading.Lock() - HEARTBEAT_INTERVAL = 15 - - - def handle(req): - if request.method == "GET": - return subscribe() - if request.method == "POST": - return publish(req) - - return "method not allowed", 405, {"Allow": "GET, POST"} - - - def subscribe(): - messages = queue.Queue(maxsize=1) - with subscribers_lock: - subscribers.add(messages) - - def stream(): - try: - yield ": connected\n\n" - while True: - try: - message = messages.get(timeout=HEARTBEAT_INTERVAL) - yield f"data: {message}\n\n" - except queue.Empty: - # Periodic writes let the server detect idle disconnects. - yield ": keep-alive\n\n" - finally: - with subscribers_lock: - subscribers.discard(messages) - - return Response( - stream(), - mimetype="text/event-stream", - headers={"Cache-Control": "no-cache"}, - ) - - - def publish(req): - body = req.decode() if isinstance(req, bytes) else str(req) - message = " ".join(body.splitlines()).strip() - if not message: - return "notification must not be empty", 400 - - delivered = broadcast(message) - return ( - json.dumps({"delivered": delivered}) + "\n", - 200, - {"Content-Type": "application/json"}, - ) - - - def broadcast(message): - delivered = 0 - with subscribers_lock: - for subscriber in subscribers: - try: - subscriber.put_nowait(message) - delivered += 1 - except queue.Full: - # Do not let a slow subscriber block every other client. - pass - - return delivered + ```javascript + 'use strict' + + let expensiveResource + let resourceReady = false + + async function setupExpensiveResource () { + // A real function could create a database client, open a persistent + // connection, or download a large dataset here. + await new Promise(resolve => setTimeout(resolve, 2000)) + return 'The expensive resource is ready' + } + + async function initialize () { + expensiveResource = await setupExpensiveResource() + resourceReady = true + } + + // Initialize once when the function process starts, not on every request. + initialize() + + module.exports = async (event, context) => { + // The readiness probe holds traffic until initialization has completed. + if (event.path === '/ready') { + return respond(context, resourceReady ? 200 : 503, + resourceReady ? 'Ready' : 'Initializing') + } + + if (!resourceReady) { + return respond(context, 503, 'Initializing') + } + + return respond(context, 200, expensiveResource) + } + + function respond (context, status, body) { + return context + .status(status) + .headers({ 'Content-Type': 'text/plain; charset=utf-8' }) + .succeed(body) + } ``` -### Stack file +## Configure the singleton and readiness === "Go" @@ -257,17 +199,17 @@ prefix with your own registry for production use. name: openfaas gateway: http://127.0.0.1:8080 functions: - notification-hub: + singleton: lang: golang-middleware - handler: ./notification-hub - image: ttl.sh/openfaas-examples/notification-hub:latest + handler: ./singleton + image: ttl.sh/openfaas-examples/singleton:latest labels: com.openfaas.scale.min: "1" com.openfaas.scale.max: "1" - environment: - exec_timeout: "1h" - read_timeout: "1h1s" - write_timeout: "1h1s" + annotations: + com.openfaas.ready.http.path: /ready + com.openfaas.ready.http.initialDelaySeconds: 1 + com.openfaas.ready.http.periodSeconds: 1 ``` === "Python" @@ -280,31 +222,51 @@ prefix with your own registry for production use. name: openfaas gateway: http://127.0.0.1:8080 functions: - notification-hub: - lang: python3-flask - handler: ./notification-hub - image: ttl.sh/openfaas-examples/python-notification-hub:latest - build_args: - TEST_ENABLED: "true" + singleton: + lang: python3-http + handler: ./singleton + image: ttl.sh/openfaas-examples/python-singleton:latest + labels: + com.openfaas.scale.min: "1" + com.openfaas.scale.max: "1" + annotations: + com.openfaas.ready.http.path: /ready + com.openfaas.ready.http.initialDelaySeconds: 1 + com.openfaas.ready.http.periodSeconds: 1 + ``` + +=== "Node.js" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + singleton: + lang: node24 + handler: ./singleton + image: ttl.sh/openfaas-examples/node-singleton:latest labels: com.openfaas.scale.min: "1" com.openfaas.scale.max: "1" - environment: - exec_timeout: "1h" - read_timeout: "1h1s" - write_timeout: "1h1s" + annotations: + com.openfaas.ready.http.path: /ready + com.openfaas.ready.http.initialDelaySeconds: 1 + com.openfaas.ready.http.periodSeconds: 1 ``` -Setting both replica limits to one disables horizontal scaling and makes the -function a singleton. With multiple replicas, a notification would only reach -subscribers connected to the replica that received it. See -[autoscaling](/architecture/autoscaling/) for more detail. +Setting both replica limits to one disables horizontal scaling. With multiple +replicas, every process would perform the setup and keep its own copy of the +resource. See [autoscaling](/architecture/autoscaling/) for more detail. -Do not set `max_inflight` to one for this function. It limits how many requests -a replica can process concurrently. Each SSE subscription remains in flight, -so a limit of one would prevent publishers from connecting. See -[concurrent request limits](/architecture/invocations/#how-many-times-can-a-function-be-invoked) -for more information on limiting requests. +The readiness annotations direct the platform probe to `/ready`. They prevent +the replica from receiving traffic while initialization is still running. +For more information, see +[Health and readiness for functions](https://www.openfaas.com/blog/health-and-readiness-for-functions/) +and [custom HTTP health checks](/reference/workloads/#custom-http-health-checks). ## Deploy and invoke @@ -314,57 +276,34 @@ Build and deploy the function: faas-cli up --tag=sha ``` -Use `faas-cli list -v` to confirm that the function has one configured and -available replica: +Confirm that the function is deployed and has one available replica: ```bash faas-cli list -v ``` -Open an SSE subscription in one terminal. The `Accept` header tells the gateway -to stream the response: - -```bash -curl -N -H "Accept: text/event-stream" \ - http://127.0.0.1:8080/function/notification-hub -``` - -Publish a notification from another terminal: +Invoke the function after it becomes ready: ```bash -curl -s -d "deployment complete" \ - http://127.0.0.1:8080/function/notification-hub | jq -``` - -The publisher reports how many subscribers accepted the notification: - -```json -{ - "delivered": 1 -} +curl http://127.0.0.1:8080/function/singleton ``` -The subscriber receives: +The function should return: ```text -data: deployment complete +The expensive resource is ready ``` -## Lifecycle and production considerations - -Fixing the desired replica count at one does not make that replica durable. The -subscriber map exists for the lifetime of the function process. A restart, -reschedule, or deployment creates a new process with an empty map, closes the -existing connections, and requires clients to reconnect. - -WebSockets have the same connection-local state concern and add bidirectional -communication. See -[How to Integrate WebSockets with Serverless Functions and OpenFaaS](https://www.openfaas.com/blog/serverless-websockets/). - -Long-lived connections also require suitable function, gateway, ingress, and -load-balancer timeouts. See [extended timeouts](/tutorials/expanded-timeouts/). - -This example uses an in-memory subscriber map to demonstrate the singleton -pattern. For a real notification implementation, consider using a message bus -to distribute notifications across replicas, and a database or durable message -bus when clients need history or guaranteed delivery. +## Singleton considerations + +* Process-local resources follow the replica lifecycle. Initialization runs + again after a deployment, restart, or rescheduling, and local state is lost + when the replica is replaced. Store durable state in external persistent + storage. +* A single replica can still process multiple requests concurrently, so shared + resources must be safe for concurrent use. Set `max_inflight: "1"` to process + one request at a time; it limits concurrency, not the replica count. See + [concurrent request limits](/architecture/invocations/#how-many-times-can-a-function-be-invoked). +* Stateful connections such as WebSockets are another use of the Singleton + pattern because their connection state remains in one process. See + [How to Integrate WebSockets with Serverless Functions and OpenFaaS](https://www.openfaas.com/blog/serverless-websockets/). From d0d3c4e1068b2289ef0127bb3cdafac8dda63186 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 3 Sep 2026 14:44:25 +0200 Subject: [PATCH 4/5] Simplify fan-out pattern example Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- docs/languages/patterns/fan-out.md | 930 ++++++++--------------------- 1 file changed, 258 insertions(+), 672 deletions(-) diff --git a/docs/languages/patterns/fan-out.md b/docs/languages/patterns/fan-out.md index 24e61abb..12393f3b 100644 --- a/docs/languages/patterns/fan-out.md +++ b/docs/languages/patterns/fan-out.md @@ -1,736 +1,369 @@ -The [Fan-out pattern](/languages/patterns/#fan-out-pattern) splits a larger -task into smaller, independent items that can be processed in parallel. +The [Fan-out pattern](/languages/patterns/#fan-out-pattern) can be used to split +a batch into independent items and submit each item to a worker function for +asynchronous processing. -Use-cases: - -* Processing a batch of independent items without keeping the caller waiting -* Absorbing bursts of work through a queue and processing them as capacity - becomes available -* Scaling the target function independently and sending each result to a - callback endpoint - -This page implements the pattern as a batch of URL health checks. The -`fan-out` function accepts one trusted URL per line and submits each URL as an -asynchronous invocation of the `url-check` function: +This page builds a deliberately small example: `fan-out` accepts a JSON array +of strings, queues one invocation of `batch-worker` for each item, and returns +the number of submitted items. ```text - [ Client ] - │ - ▼ POST /function/fan-out -┌──────────────────────┐ -│ fan-out │ ◄── splits the batch and returns a summary -└──────────┬───────────┘ - │ - ▼ -┌──────────────────────┐ -│ queue-worker │ ◄── drains the queue as capacity becomes available -└──────────┬───────────┘ - ├── URL 1 / async ──► [ url-check ] ──┐ - ├── URL 2 / async ──► [ url-check ] ──┤ - └── URL N / async ──► [ url-check ] ──┘ - │ optional callback - ▼ - [ Result endpoint ] + [ Client ] + │ batch with three items + ▼ + [ fan-out ] ──► [ Async queue ] ──► [ Queue-worker ] + │ ├── "one" ───► [ batch-worker ] + │ ├── "two" ───► [ batch-worker ] + │ └── "three" ─► [ batch-worker ] + ▼ +[ Response: 202 {"submitted": 3} ] ``` -The example demonstrates three parts of fan-out: +This simple batch highlights several properties of the Fan-out pattern: -* **Submission:** the caller receives call IDs without waiting for the URL - checks to finish. -* **Queued processing:** the queue-worker invokes `url-check` as capacity - becomes available, and OpenFaaS can scale the function across replicas. -* **Result delivery:** an optional callback URL receives each health-check - result independently. +* **Batch splitting:** `fan-out` creates one asynchronous invocation for each + item in the input batch. +* **Asynchronous response:** the caller receives a response after the items + have been accepted by the queue, without waiting for processing to finish. +* **Independent processing:** items can be processed concurrently and may + complete in a different order from the input. +* **Independent scaling:** the queue provides back pressure while OpenFaaS can + scale the `batch-worker` function to handle the load. ## Create the functions -Choose a language and scaffold both functions in a single `stack.yaml` file: +Choose a language and scaffold both functions in one `stack.yaml` file: === "Go" ```bash faas-cli template store pull golang-middleware - faas-cli new --lang golang-middleware fan-out \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang golang-middleware url-check \ + faas-cli new --lang golang-middleware batch-worker \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files and `stack.yaml` with the Go files from - the implementation section below. - === "Python" ```bash faas-cli template store pull python3-http - faas-cli new --lang python3-http fan-out \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang python3-http url-check \ + faas-cli new --lang python3-http batch-worker \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files, both `requirements.txt` files, and - `stack.yaml` with the Python files from the implementation section below. - === "Node.js" ```bash faas-cli template store pull node24 - faas-cli new --lang node24 fan-out \ --prefix ttl.sh/openfaas-examples - - faas-cli new --lang node24 url-check \ + faas-cli new --lang node24 batch-worker \ --append stack.yaml --prefix ttl.sh/openfaas-examples ``` - Replace the generated handler files and `stack.yaml` with the Node.js files - from the implementation section below. - The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the prefix with your own registry for production use. -## Implement the functions +The full source code and `stack.yaml` files are available on GitHub for +[Go](https://github.com/openfaas/function-patterns/tree/master/go/fan-out), +[Python](https://github.com/openfaas/function-patterns/tree/master/python/fan-out), +and [Node.js](https://github.com/openfaas/function-patterns/tree/master/node/fan-out). -### Submitting function: fan-out +## Implement the worker + +The worker receives one string from the batch and returns a JSON result. A real +worker could transform a record, generate a report, or process a file stored in +object storage. === "Go" - `fan-out/handler.go`: + `batch-worker/handler.go`: ```go package function import ( - "bytes" - "context" "encoding/json" - "fmt" "io" "net/http" - "os" - "strings" - "time" - ) - - const ( - targetFunction = "url-check" - submitTimeout = 30 * time.Second ) - type Response struct { - Submitted int `json:"submitted"` - Function string `json:"function"` - Callback bool `json:"callback"` - CallIDs []string `json:"call_ids,omitempty"` - } - - // Handle takes a HTTP request body and splits it into one record per line. - // Each record is submitted as an asynchronous invocation of the target - // function, then a summary is returned to the caller without waiting for - // the function invocations to complete. func Handle(w http.ResponseWriter, r *http.Request) { - input, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "unable to read request body", http.StatusBadRequest) - return - } defer r.Body.Close() - gateway := os.Getenv("gateway_url") - if gateway == "" { - gateway = "http://gateway.openfaas:8080" - } - - // Forward the callback URL to every asynchronous invocation. A header on - // the batch request overrides the environment variable. - callback := strings.TrimSpace(r.Header.Get("X-Callback-Url")) - if callback == "" { - callback = strings.TrimSpace(os.Getenv("callback_url")) - } - - records := recordsFromInput(string(input)) - if len(records) == 0 { - http.Error( - w, - "expected one record per line in the request body", - http.StatusBadRequest, - ) + item, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "unable to read item", http.StatusBadRequest) return } - submitted := 0 - var callIDs []string - - for i, record := range records { - callID, err := submit( - r.Context(), gateway, targetFunction, record, callback, - ) - if err != nil { - message := fmt.Sprintf( - "record %d of %d: %s", - i+1, - len(records), - err, - ) - http.Error(w, message, http.StatusBadGateway) - return - } - - submitted++ - if callID != "" { - callIDs = append(callIDs, callID) - } - } - - res := Response{ - Submitted: submitted, - Function: targetFunction, - Callback: callback != "", - CallIDs: callIDs, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(res) - } - - func recordsFromInput(input string) []string { - var records []string - - for _, record := range strings.Split(strings.TrimSpace(input), "\n") { - record = strings.TrimSpace(record) - if record != "" { - records = append(records, record) - } - } - - return records - } - - func submit( - ctx context.Context, - gateway string, - targetFunction string, - record string, - callback string, - ) (string, error) { - ctx, cancel := context.WithTimeout(ctx, submitTimeout) - defer cancel() - - url := strings.TrimRight(gateway, "/") + "/async-function/" + targetFunction - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - url, - bytes.NewReader([]byte(record)), - ) - if err != nil { - return "", fmt.Errorf("unable to invoke %s: %w", targetFunction, err) - } - req.Header.Set("Content-Type", "text/plain") - if callback != "" { - req.Header.Set("X-Callback-Url", callback) - } - - res, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("error invoking %s: %w", targetFunction, err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusAccepted { - out, err := io.ReadAll(res.Body) - if err != nil { - return "", fmt.Errorf( - "unexpected status %d from %s", - res.StatusCode, - targetFunction, - ) - } - - return "", fmt.Errorf( - "unexpected status %d from %s: %s", - res.StatusCode, - targetFunction, - string(out), - ) - } - - // the X-Call-Id header can be used to track or cancel the record - return res.Header.Get("X-Call-Id"), nil + json.NewEncoder(w).Encode(map[string]interface{}{ + "item": string(item), + "processed": true, + }) } ``` === "Python" - `fan-out/handler.py`: + `batch-worker/handler.py`: ```python - import os - - import requests - - - TARGET_FUNCTION = "url-check" - SUBMIT_TIMEOUT = 30 - - def handle(event, context): - body = ( + item = ( event.body.decode() if isinstance(event.body, bytes) else str(event.body) ) - records = [ - record.strip() - for record in body.strip().splitlines() - if record.strip() - ] - if not records: - return error(400, "expected one record per line in the request body") - - gateway = os.getenv("gateway_url", "http://gateway.openfaas:8080") - callback = event.headers.get("X-Callback-Url", "").strip() - if not callback: - callback = os.getenv("callback_url", "").strip() - - call_ids = [] - for index, record in enumerate(records): - try: - call_id = submit(gateway, record, callback) - except requests.RequestException as err: - return error(502, f"record {index + 1} of {len(records)}: {err}") - except RuntimeError as err: - return error(502, f"record {index + 1} of {len(records)}: {err}") - - if call_id: - call_ids.append(call_id) - response = { - "submitted": len(records), - "function": TARGET_FUNCTION, - "callback": bool(callback), + return { + "statusCode": 200, + "body": {"item": item, "processed": True}, } - if call_ids: - response["call_ids"] = call_ids - - return {"statusCode": 200, "body": response} - - - def submit(gateway, record, callback): - headers = {"Content-Type": "text/plain"} - if callback: - headers["X-Callback-Url"] = callback - - response = requests.post( - f"{gateway.rstrip('/')}/async-function/{TARGET_FUNCTION}", - data=record.encode(), - headers=headers, - timeout=SUBMIT_TIMEOUT, - ) - if response.status_code != 202: - raise RuntimeError( - f"unexpected status {response.status_code} " - f"from {TARGET_FUNCTION}: {response.text}" - ) - - return response.headers.get("X-Call-Id", "") - - - def error(status_code, message): - return {"statusCode": status_code, "body": message} - ``` - - `fan-out/requirements.txt`: - - ```text - requests ``` === "Node.js" - `fan-out/handler.js`: + `batch-worker/handler.js`: ```javascript 'use strict' - const targetFunction = 'url-check' - const submitTimeout = 30000 - module.exports = async (event, context) => { - const input = requestBody(event.body) - const records = input - .trim() - .split('\n') - .map((record) => record.trim()) - .filter(Boolean) - - if (records.length === 0) { - return fail( - context, - 400, - 'expected one record per line in the request body' - ) - } - - const gateway = process.env.gateway_url || - 'http://gateway.openfaas:8080' - const headers = event.headers || {} - const callback = String( - headers['x-callback-url'] || process.env.callback_url || '' - ).trim() - - const callIDs = [] - for (const [index, record] of records.entries()) { - let callID - try { - callID = await submit(gateway, record, callback) - } catch (error) { - return fail( - context, - 502, - `record ${index + 1} of ${records.length}: ${error.message}` - ) - } - if (callID) { - callIDs.push(callID) - } - } - - const response = { - submitted: records.length, - function: targetFunction, - callback: Boolean(callback) - } - if (callIDs.length > 0) { - response.call_ids = callIDs - } + const item = Buffer.isBuffer(event.body) + ? event.body.toString() + : String(event.body || '') return context .status(200) .headers({ 'Content-Type': 'application/json' }) - .succeed(response) - } - - async function submit (gateway, record, callback) { - const headers = { 'Content-Type': 'text/plain' } - if (callback) { - headers['X-Callback-Url'] = callback - } - - const baseURL = gateway.replace(/\/$/, '') - const response = await fetch( - `${baseURL}/async-function/${targetFunction}`, - { - method: 'POST', - headers, - body: record, - signal: AbortSignal.timeout(submitTimeout) - } - ) - - if (response.status !== 202) { - const body = await response.text() - throw new Error( - `unexpected status ${response.status} ` + - `from ${targetFunction}: ${body}` - ) - } - - return response.headers.get('X-Call-Id') || '' - } - - function requestBody (body) { - if (Buffer.isBuffer(body)) { - return body.toString() - } - return String(body || '') - } - - function fail (context, status, message) { - return context - .status(status) - .headers({ 'Content-Type': 'text/plain' }) - .succeed(message) + .succeed({ item, processed: true }) } ``` -The submitting function: +## Implement fan-out -* Uses the in-cluster gateway URL by default and submits each URL through - `/async-function/url-check`. -* Forwards `X-Callback-Url` from the batch request to every asynchronous - invocation. The `callback_url` environment variable can provide a default. -* Returns the `X-Call-Id` from each accepted submission so individual checks - can be tracked or cancelled. -* Stops and returns `502 Bad Gateway` if the gateway does not accept one of the - submissions. Checks accepted before that failure remain queued. - -### Fanned-out function: url-check - -The function performs an HTTP `GET` with a configurable timeout and returns a -structured health result. +The `fan-out` function decodes the batch and posts each item to the gateway's +`/async-function/batch-worker` route. Each `202 Accepted` response confirms +that an item was queued, not that the worker has completed it. === "Go" - `url-check/handler.go`: + `fan-out/handler.go`: ```go package function import ( + "bytes" "context" "encoding/json" "fmt" - "io" "net/http" - "net/url" "os" "strings" "time" ) const ( - defaultRequestTimeout = 5 * time.Second - maxURLLength = 4096 + workerFunction = "batch-worker" + submitTimeout = 5 * time.Second ) - var requestTimeout = defaultRequestTimeout - - func init() { - value := os.Getenv("request_timeout") - if value == "" { - return - } - - timeout, err := time.ParseDuration(value) - if err != nil || timeout <= 0 { - panic(fmt.Sprintf("invalid request_timeout %q", value)) - } - - requestTimeout = timeout - } - - type Response struct { - URL string `json:"url"` - Reachable bool `json:"reachable"` - Healthy bool `json:"healthy"` - StatusCode int `json:"status_code,omitempty"` - ContentType string `json:"content_type,omitempty"` - DurationMs int64 `json:"duration_ms"` - Error string `json:"error,omitempty"` - } - func Handle(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - input, err := io.ReadAll(io.LimitReader(r.Body, maxURLLength+1)) - if err != nil { - http.Error(w, "unable to read request body", http.StatusBadRequest) + var batch []string + if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { + http.Error(w, "expected a JSON array of strings", http.StatusBadRequest) return } - if len(input) > maxURLLength { - http.Error(w, "URL is too long", http.StatusBadRequest) + if len(batch) == 0 { + http.Error(w, "batch must contain at least one item", http.StatusBadRequest) return } - target := strings.TrimSpace(string(input)) - parsed, err := url.ParseRequestURI(target) - if err != nil || parsed.Host == "" { - http.Error( - w, - "expected an absolute HTTP or HTTPS URL", - http.StatusBadRequest, - ) - return + gateway := os.Getenv("gateway_url") + if gateway == "" { + gateway = "http://gateway.openfaas:8080" } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - http.Error( - w, - "expected an absolute HTTP or HTTPS URL", - http.StatusBadRequest, - ) - return + endpoint := strings.TrimRight(gateway, "/") + + "/async-function/" + workerFunction + callback := r.Header.Get("X-Callback-Url") + + // Submit one asynchronous invocation for each item in the batch. + for i, item := range batch { + if err := submit(r.Context(), endpoint, item, callback); err != nil { + http.Error(w, fmt.Sprintf("item %d: %s", i+1, err), http.StatusBadGateway) + return + } } - start := time.Now() - ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(map[string]int{"submitted": len(batch)}) + } + + func submit(parent context.Context, endpoint, item, callback string) error { + ctx, cancel := context.WithTimeout(parent, submitTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint, + bytes.NewBufferString(item), + ) if err != nil { - http.Error( - w, - "unable to create health-check request", - http.StatusBadRequest, - ) - return + return err } - req.Header.Set("User-Agent", "OpenFaaS URL health check") - - res, requestErr := http.DefaultClient.Do(req) - result := Response{ - URL: target, - DurationMs: time.Since(start).Milliseconds(), + req.Header.Set("Content-Type", "text/plain") + if callback != "" { + req.Header.Set("X-Callback-Url", callback) } - if requestErr != nil { - result.Error = requestErr.Error() - writeJSON(w, result) - return + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err } defer res.Body.Close() - io.Copy(io.Discard, io.LimitReader(res.Body, 1024)) - result.Reachable = true - result.Healthy = res.StatusCode >= 200 && res.StatusCode < 400 - result.StatusCode = res.StatusCode - result.ContentType = res.Header.Get("Content-Type") - writeJSON(w, result) - } + if res.StatusCode != http.StatusAccepted { + return fmt.Errorf("queue returned %s", res.Status) + } - func writeJSON(w http.ResponseWriter, result Response) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) + return nil } ``` === "Python" - `url-check/handler.py`: + `fan-out/handler.py`: ```python + import json import os - import time - from urllib.parse import urlparse import requests - MAX_URL_LENGTH = 4096 - REQUEST_TIMEOUT = float(os.getenv("request_timeout", "5")) - if REQUEST_TIMEOUT <= 0: - raise ValueError("request_timeout must be greater than zero") + WORKER_FUNCTION = "batch-worker" + SUBMIT_TIMEOUT = 5 def handle(event, context): - body = ( - event.body - if isinstance(event.body, bytes) - else str(event.body).encode() - ) - if len(body) > MAX_URL_LENGTH: - return error("URL is too long") - - target = body.decode().strip() - parsed = urlparse(target) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - return error("expected an absolute HTTP or HTTPS URL") - - started = time.monotonic() - result = { - "url": target, - "reachable": False, - "healthy": False, - } - try: - with requests.get( - target, - headers={"User-Agent": "OpenFaaS URL health check"}, - timeout=REQUEST_TIMEOUT, - stream=True, - ) as response: - response.raw.read(1024) - result.update( - { - "reachable": True, - "healthy": 200 <= response.status_code < 400, - "status_code": response.status_code, - "content_type": response.headers.get("Content-Type", ""), - "duration_ms": int((time.monotonic() - started) * 1000), - } - ) - except requests.RequestException as err: - result["duration_ms"] = int((time.monotonic() - started) * 1000) - result["error"] = str(err) - return {"statusCode": 200, "body": result} + batch = json.loads(event.body) + except (TypeError, ValueError): + return error(400, "expected a JSON array of strings") - return {"statusCode": 200, "body": result} + if ( + not isinstance(batch, list) + or not batch + or not all(isinstance(item, str) for item in batch) + ): + return error(400, "expected a non-empty JSON array of strings") + gateway = os.getenv("gateway_url", "http://gateway.openfaas:8080") + endpoint = f"{gateway.rstrip('/')}/async-function/{WORKER_FUNCTION}" + callback = event.headers.get("X-Callback-Url", "") - def error(message): - return {"statusCode": 400, "body": message} - ``` + # Submit one asynchronous invocation for each item in the batch. + for index, item in enumerate(batch): + headers = {"Content-Type": "text/plain"} + if callback: + headers["X-Callback-Url"] = callback + + try: + response = requests.post( + endpoint, + data=item.encode(), + headers=headers, + timeout=SUBMIT_TIMEOUT, + ) + except requests.RequestException as err: + return error(502, f"item {index + 1}: {err}") + + if response.status_code != 202: + return error( + 502, + f"item {index + 1}: queue returned {response.status_code}", + ) + + return {"statusCode": 202, "body": {"submitted": len(batch)}} - `url-check/requirements.txt`: - ```text - requests + def error(status_code, message): + return {"statusCode": status_code, "body": message} ``` + Add `requests` to `fan-out/requirements.txt`. + === "Node.js" - `url-check/handler.js`: + `fan-out/handler.js`: ```javascript 'use strict' - const { performance } = require('node:perf_hooks') - - const maxURLLength = 4096 - const requestTimeout = configuredTimeout( - process.env.request_timeout || '5', - 'request_timeout' - ) + const workerFunction = 'batch-worker' + const submitTimeout = 5000 module.exports = async (event, context) => { - const target = requestBody(event.body).trim() - if (Buffer.byteLength(target) > maxURLLength) { - return fail(context, 'URL is too long') - } - - let parsed + let batch try { - parsed = new URL(target) + batch = JSON.parse(requestBody(event.body)) } catch (error) { - return fail(context, 'expected an absolute HTTP or HTTPS URL') - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return fail(context, 'expected an absolute HTTP or HTTPS URL') + return fail(context, 400, 'expected a JSON array of strings') } - const started = performance.now() - const result = { - url: target, - reachable: false, - healthy: false + if (!Array.isArray(batch) || batch.length === 0 || + !batch.every(item => typeof item === 'string')) { + return fail(context, 400, 'expected a non-empty JSON array of strings') } - let response - try { - response = await fetch(target, { - headers: { 'User-Agent': 'OpenFaaS URL health check' }, - signal: AbortSignal.timeout(requestTimeout) - }) - } catch (error) { - result.duration_ms = Math.round(performance.now() - started) - result.error = error.message - return succeed(context, result) - } + const gateway = process.env.gateway_url || + 'http://gateway.openfaas:8080' + const endpoint = `${gateway.replace(/\/$/, '')}/async-function/${workerFunction}` + const callback = String( + (event.headers || {})['x-callback-url'] || '' + ) + + // Submit one asynchronous invocation for each item in the batch. + for (const [index, item] of batch.entries()) { + const headers = { 'Content-Type': 'text/plain' } + if (callback) { + headers['X-Callback-Url'] = callback + } + + let response + try { + response = await fetch(endpoint, { + method: 'POST', + headers, + body: item, + signal: AbortSignal.timeout(submitTimeout) + }) + } catch (error) { + return fail(context, 502, `item ${index + 1}: ${error.message}`) + } - if (response.body) { - await response.body.cancel() + if (response.status !== 202) { + return fail( + context, + 502, + `item ${index + 1}: queue returned ${response.status}` + ) + } } - result.reachable = true - result.healthy = response.status >= 200 && response.status < 400 - result.status_code = response.status - result.content_type = response.headers.get('Content-Type') || '' - result.duration_ms = Math.round(performance.now() - started) - return succeed(context, result) + + return context + .status(202) + .headers({ 'Content-Type': 'application/json' }) + .succeed({ submitted: batch.length }) } function requestBody (body) { @@ -740,40 +373,20 @@ structured health result. return String(body || '') } - function configuredTimeout (value, name) { - const seconds = Number(value) - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error(`${name} must be greater than zero`) - } - return seconds * 1000 - } - - function succeed (context, body) { - return context - .status(200) - .headers({ 'Content-Type': 'application/json' }) - .succeed(body) - } - - function fail (context, message) { + function fail (context, status, message) { return context - .status(400) + .status(status) .headers({ 'Content-Type': 'text/plain' }) .succeed(message) } ``` -Each URL produces a structured result, including unreachable targets and -timeouts, so every outcome can be delivered to the callback endpoint. - -!!! warning +The five-second submission timeout belongs to the `fan-out` implementation in +this example. It limits how long the function waits for the gateway to accept +each item; it does not limit how long `batch-worker` may run after the item has +been queued. - Only submit URLs from a trusted source. Fetching arbitrary user-provided - URLs can expose internal services through server-side request forgery - (SSRF). For a public endpoint, enforce an allow-list and validate resolved - addresses before making the request. - -### Stack file +## Configure the functions === "Go" @@ -790,12 +403,10 @@ timeouts, so every outcome can be delivered to the callback endpoint. handler: ./fan-out image: ttl.sh/openfaas-examples/fan-out:latest - url-check: + batch-worker: lang: golang-middleware - handler: ./url-check - image: ttl.sh/openfaas-examples/url-check:latest - environment: - request_timeout: 5s + handler: ./batch-worker + image: ttl.sh/openfaas-examples/batch-worker:latest ``` === "Python" @@ -815,14 +426,12 @@ timeouts, so every outcome can be delivered to the callback endpoint. build_args: TEST_ENABLED: "true" - url-check: + batch-worker: lang: python3-http - handler: ./url-check - image: ttl.sh/openfaas-examples/python-url-check:latest + handler: ./batch-worker + image: ttl.sh/openfaas-examples/python-batch-worker:latest build_args: TEST_ENABLED: "true" - environment: - request_timeout: "5" ``` === "Node.js" @@ -840,56 +449,51 @@ timeouts, so every outcome can be delivered to the callback endpoint. handler: ./fan-out image: ttl.sh/openfaas-examples/node-fan-out:latest - url-check: + batch-worker: lang: node24 - handler: ./url-check - image: ttl.sh/openfaas-examples/node-url-check:latest - environment: - request_timeout: "5" + handler: ./batch-worker + image: ttl.sh/openfaas-examples/node-batch-worker:latest ``` ## Deploy and submit a batch -Build, push, and deploy both functions: +Build and deploy both functions: ```bash faas-cli up --tag=sha ``` -The handler splits the request body on newlines, so use `--data-binary` with `curl`: +Submit a batch: ```bash -printf 'https://www.openfaas.com/\nhttps://docs.openfaas.com/\n' | \ - curl -s --data-binary @- -H "Content-Type: text/plain" \ - http://127.0.0.1:8080/function/fan-out | jq +curl -i http://127.0.0.1:8080/function/fan-out \ + -H "Content-Type: application/json" \ + -d '["one", "two", "three"]' +``` + +The function confirms that all three items have been accepted by the queue: + +```text +HTTP/1.1 202 Accepted +Content-Type: application/json + +{"submitted":3} ``` -The response confirms that both checks were accepted without waiting for them -to finish: +The queue-worker then invokes `batch-worker` once for each item. Its response +for the first item is: ```json { - "submitted": 2, - "function": "url-check", - "callback": false, - "call_ids": [ - "9c0b1a12-fdea-4f01-baff-c5d9f50435ea", - "4111d512-cdf3-4b8f-96b3-1b7f1f376bd7" - ] + "item": "one", + "processed": true } ``` -## Collect individual results with a callback - -By default, the queue-worker discards the response from each `url-check` -invocation. To receive the responses, set `X-Callback-Url` on the request to -`fan-out`. The submitting function copies that URL to every queued invocation, -and the queue-worker posts each result to the callback endpoint. - -Callbacks are independent and may arrive in a different order from the input. -This example delivers each result but does not wait for or combine the whole -batch. When that is required, the callback endpoint can use shared storage to -track progress and [fan the results back in](https://www.openfaas.com/blog/fan-out-and-back-in-using-functions/). +By default, responses from asynchronous invocations are discarded. To receive +each result, set `X-Callback-Url` on the request to `fan-out`. The function +forwards the header to each queued invocation, and the queue-worker sends each +result to that endpoint. Callbacks are independent and may arrive out of order. ### Receive callback results @@ -903,55 +507,37 @@ faas-cli logs printer -t In another terminal, submit the batch with a callback URL: ```bash -printf 'https://www.openfaas.com/\nhttps://docs.openfaas.com/\n' | \ - curl -s --data-binary @- \ - -H "Content-Type: text/plain" \ +curl -s http://127.0.0.1:8080/function/fan-out \ + -H "Content-Type: application/json" \ -H "X-Callback-Url: http://gateway.openfaas:8080/function/printer" \ - http://127.0.0.1:8080/function/fan-out | jq + -d '["one", "two", "three"]' ``` -The batch response now contains `"callback": true`. The `printer` logs receive -one callback per URL, with a body similar to: +The `printer` logs receive one callback per item, with a body similar to: ```json { - "url": "https://www.openfaas.com/", - "reachable": true, - "healthy": true, - "status_code": 200, - "content_type": "text/html; charset=utf-8", - "duration_ms": 84 + "item": "one", + "processed": true } ``` -The `printer` function is useful for demonstrating callback delivery. Replace -it with an application endpoint when results need to be persisted or acted on. - -## Track and cancel checks - -Each call ID in the batch response identifies one queued check. Cancel it with -a `DELETE` request to the async endpoint: - -```bash -curl -i -X DELETE \ - http://127.0.0.1:8080/async-function/9c0b1a12-fdea-4f01-baff-c5d9f50435ea -``` - -A `202 Accepted` response indicates that the cancellation request was -accepted. See [asynchronous functions](/reference/async/) for the complete -lifecycle. - -## Operational considerations - -* The queue-worker processes records up to its configured `max_inflight` - concurrency, while OpenFaaS can - [autoscale the function](/architecture/autoscaling/) across replicas. See - [parallelism](/reference/async/#parallelism). -* A target that cannot be reached produces a successful function invocation - with `"reachable": false`. This allows the failure result to reach the - callback rather than being retried as a function error. -* Queue-worker retries apply when the function invocation itself fails. See - [retries](/openfaas-pro/retries/). -* The maximum payload size for each queued item is 1MB. For larger inputs, - store the data externally and submit an identifier. See - [configuration and limits](/reference/async/#configuration-limits). +When one action should run only after the complete batch has finished, use the +[fan-in pattern](/languages/patterns/fan-in/) to collect and combine the individual +results. + +## Fan-out considerations + +* Batch items must be independent. If one item needs another item's result, + coordinate them in sequence instead. +* The gateway returns an `X-Call-Id` for each accepted asynchronous + invocation. Retain these IDs when individual items need to be tracked or + cancelled with `DELETE /async-function/`. See + [cancel asynchronous invocations](/reference/async/#cancel-async-invocations) + for more information. +* The queue-worker controls how many items it + [processes concurrently](/reference/async/#parallelism), and OpenFaaS can + [autoscale](/architecture/autoscaling/) `batch-worker` when the load increases. +* The queue-worker handles + [retries for failed invocations](/openfaas-pro/retries/), allowing individual + batch items to recover from temporary errors. From 2c9e752d694488ddc0ac8cc917fdf6c21d3c5993 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 3 Sep 2026 16:24:07 +0200 Subject: [PATCH 5/5] Add fan-in pattern example Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- docs/languages/patterns/fan-in.md | 672 ++++++++++++++++++++++++++++++ mkdocs.yml | 5 +- 2 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 docs/languages/patterns/fan-in.md diff --git a/docs/languages/patterns/fan-in.md b/docs/languages/patterns/fan-in.md new file mode 100644 index 00000000..dcaba1e7 --- /dev/null +++ b/docs/languages/patterns/fan-in.md @@ -0,0 +1,672 @@ +The Fan-in pattern complements the +[Fan-out pattern](/languages/patterns/#fan-out-pattern) by bringing the +independent results back together. The workflow can then continue after every +item in the batch has completed. + +A Fan-in implementation associates each result with its batch, records +progress as results arrive, and triggers the next step once all expected work +is complete. It should also store the individual results when the next step needs +to combine them. + +This example extends the [Fan-out example](/languages/patterns/fan-out/) by +sending the result of each `batch-worker` invocation to a `fan-in` function. +The function uses a PostgreSQL counter to track completion and invokes the +next function when the counter reaches zero. + +```text +1. Initialize the batch + +[ Client ] ── size 3 ──► [ fan-in ] ──► [ PostgreSQL: remaining 3 ] + ▲ │ + └────── batch ID ─────────┘ + +2. Fan the callbacks back in + +[ Queue-worker ] + ├── "one" ───► [ batch-worker ] ──┐ + ├── "two" ───► [ batch-worker ] ──┼── callbacks ──► [ fan-in ] + └── "three" ─► [ batch-worker ] ──┘ │ + ▼ + [ PostgreSQL: 3 → 2 → 1 → 0 ] + │ + ▼ + [ printer ] +``` + +* A counter is created with the number of items before any + work is submitted. +* Every worker result is sent to the same callback function + with the batch ID. +* PostgreSQL updates the counter atomically, so callbacks + can arrive concurrently and reach different `fan-in` replicas. +* Only the callback that changes the counter to zero invokes + the next function for the completed batch. + +The example only tracks completion. A workflow that needs to combine results +would also store each callback body under the batch ID, then have the final +function read and combine those results. + +## Create the function + +The `fan-out` and `batch-worker` functions come from the +[Fan-out example](/languages/patterns/fan-out/). Scaffold the additional `fan-in` function: + +=== "Go" + + ```bash + mkdir fan-in && cd fan-in + faas-cli template store pull golang-middleware + faas-cli new --lang golang-middleware fan-in \ + --prefix ttl.sh/openfaas-examples + ``` + +=== "Python" + + ```bash + mkdir fan-in && cd fan-in + faas-cli template store pull python3-http + faas-cli new --lang python3-http fan-in \ + --prefix ttl.sh/openfaas-examples + ``` + +=== "Node.js" + + ```bash + mkdir fan-in && cd fan-in + faas-cli template store pull node24 + faas-cli new --lang node24 fan-in \ + --prefix ttl.sh/openfaas-examples + ``` + +The example uses the public [ttl.sh](https://ttl.sh) registry. Replace the +prefix with your own registry for production use. + +The full source code and `stack.yaml` files are available on GitHub for +[Go](https://github.com/openfaas/function-patterns/tree/master/go/fan-in), +[Python](https://github.com/openfaas/function-patterns/tree/master/python/fan-in), +and [Node.js](https://github.com/openfaas/function-patterns/tree/master/node/fan-in). + +## Configure PostgreSQL + +Create a table with one row for each batch. Completed rows remain at zero so a +late callback cannot invoke the final function again. + +`fan-in/schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS batch_counters ( + batch_id text PRIMARY KEY, + remaining integer NOT NULL CHECK (remaining >= 0) +); +``` + +Apply the schema to a PostgreSQL database that the function can reach: + +```bash +psql "$POSTGRES_CONNECTION" -f fan-in/schema.sql +``` + +Store the same connection string in `postgres-connection.txt`, then create an +OpenFaaS secret from it: + +```bash +faas-cli secret create postgres-connection \ + --from-file=postgres-connection.txt +``` + +The function reads the connection string from +`/var/openfaas/secrets/postgres-connection`. + +## Implement fan-in + +The function exposes two paths: + +* `POST /batch` accepts a size, creates the counter, and returns a generated + batch ID. +* `POST /callback?batch_id=` atomically decrements the counter. The + callback that changes it to zero posts a completion message to `notify_url`. + +The SQL statement used by `/callback` both updates and reads the counter. The +`decremented` value distinguishes the callback that completed the batch from a +later callback that reads the existing zero. + +=== "Go" + + `fan-in/handler.go`: + + ```go + package function + + import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/jackc/pgx/v5" + ) + + const ( + connectionSecret = "/var/openfaas/secrets/postgres-connection" + notifyTimeout = 5 * time.Second + ) + + const decrementQuery = ` + WITH updated AS ( + UPDATE batch_counters + SET remaining = remaining - 1 + WHERE batch_id = $1 + AND remaining > 0 + RETURNING remaining + ) + SELECT remaining, true + FROM updated + UNION ALL + SELECT remaining, false + FROM batch_counters + WHERE batch_id = $1 + AND NOT EXISTS (SELECT 1 FROM updated) + LIMIT 1` + + type postgresCounter struct { + conn *pgx.Conn + } + + func Handle(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + connectionString, err := postgresConnection() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + conn, err := pgx.Connect(r.Context(), connectionString) + if err != nil { + http.Error(w, "connect to PostgreSQL", http.StatusBadGateway) + return + } + defer conn.Close(r.Context()) + + counter := postgresCounter{conn: conn} + switch r.URL.Path { + case "/batch": + createBatch(w, r, counter) + case "/callback": + completeItem(w, r, counter) + default: + http.NotFound(w, r) + } + } + + func createBatch(w http.ResponseWriter, r *http.Request, counter postgresCounter) { + var input struct { + Size int `json:"size"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || + input.Size < 1 { + http.Error(w, "expected a positive size", http.StatusBadRequest) + return + } + + var batchID string + err := counter.conn.QueryRow(r.Context(), ` + INSERT INTO batch_counters (batch_id, remaining) + VALUES (gen_random_uuid()::text, $1) + RETURNING batch_id`, input.Size).Scan(&batchID) + if err != nil { + http.Error(w, "create batch", http.StatusBadGateway) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "batch_id": batchID, + "remaining": input.Size, + }) + } + + func completeItem(w http.ResponseWriter, r *http.Request, counter postgresCounter) { + batchID := strings.TrimSpace(r.URL.Query().Get("batch_id")) + if batchID == "" { + http.Error(w, "batch_id is required", http.StatusBadRequest) + return + } + + var remaining int + var decremented bool + err := counter.conn.QueryRow(r.Context(), decrementQuery, batchID). + Scan(&remaining, &decremented) + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "batch not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "update batch", http.StatusBadGateway) + return + } + + complete := remaining == 0 + if complete && decremented { + if err := notify(r.Context(), batchID); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "batch_id": batchID, + "remaining": remaining, + "complete": complete, + }) + } + + func postgresConnection() (string, error) { + value, err := os.ReadFile(connectionSecret) + if err != nil { + return "", err + } + return strings.TrimSpace(string(value)), nil + } + + func notify(parent context.Context, batchID string) error { + body, _ := json.Marshal(map[string]string{ + "batch_id": batchID, + "status": "complete", + }) + + ctx, cancel := context.WithTimeout(parent, notifyTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, http.MethodPost, os.Getenv("notify_url"), bytes.NewReader(body), + ) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("notification endpoint returned %s", res.Status) + } + return nil + } + + func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(value) + } + ``` + + Add `github.com/jackc/pgx/v5` to `fan-in/go.mod`. + +=== "Python" + + `fan-in/handler.py`: + + ```python + import json + import os + + import psycopg + import requests + + + CONNECTION_SECRET = "/var/openfaas/secrets/postgres-connection" + NOTIFY_TIMEOUT = 5 + + DECREMENT_QUERY = """ + WITH updated AS ( + UPDATE batch_counters + SET remaining = remaining - 1 + WHERE batch_id = %s + AND remaining > 0 + RETURNING remaining + ) + SELECT remaining, true + FROM updated + UNION ALL + SELECT remaining, false + FROM batch_counters + WHERE batch_id = %s + AND NOT EXISTS (SELECT 1 FROM updated) + LIMIT 1 + """ + + + def handle(event, context): + try: + with psycopg.connect(postgres_connection(), autocommit=True) as connection: + if event.path == "/batch": + return create_batch(event, connection) + if event.path == "/callback": + return complete_item(event, connection) + return error(404, "not found") + except (KeyError, OSError, psycopg.Error, requests.RequestException) as err: + return error(502, str(err)) + + + def create_batch(event, connection): + try: + size = int(json.loads(event.body)["size"]) + except (KeyError, TypeError, ValueError): + return error(400, "expected a positive size") + + if size < 1: + return error(400, "expected a positive size") + + row = connection.execute( + """ + INSERT INTO batch_counters (batch_id, remaining) + VALUES (gen_random_uuid()::text, %s) + RETURNING batch_id + """, + (size,), + ).fetchone() + + return { + "statusCode": 201, + "body": {"batch_id": row[0], "remaining": size}, + } + + + def complete_item(event, connection): + batch_id = event.query.get("batch_id", "").strip() + if not batch_id: + return error(400, "batch_id is required") + + row = connection.execute( + DECREMENT_QUERY, + (batch_id, batch_id), + ).fetchone() + if row is None: + return error(404, "batch not found") + + remaining, decremented = row + complete = remaining == 0 + if complete and decremented: + response = requests.post( + os.environ["notify_url"], + json={"batch_id": batch_id, "status": "complete"}, + timeout=NOTIFY_TIMEOUT, + ) + response.raise_for_status() + + return { + "statusCode": 200, + "body": { + "batch_id": batch_id, + "remaining": remaining, + "complete": complete, + }, + } + + + def postgres_connection(): + with open(CONNECTION_SECRET, encoding="utf-8") as secret: + return secret.read().strip() + + + def error(status_code, message): + return {"statusCode": status_code, "body": message} + ``` + + Add `psycopg[binary]` and `requests` to `fan-in/requirements.txt`. + +=== "Node.js" + + `fan-in/handler.js`: + + ```javascript + 'use strict' + + const fs = require('node:fs') + const { Client } = require('pg') + + const connectionSecret = '/var/openfaas/secrets/postgres-connection' + const notifyTimeout = 5000 + + const decrementQuery = ` + WITH updated AS ( + UPDATE batch_counters + SET remaining = remaining - 1 + WHERE batch_id = $1 + AND remaining > 0 + RETURNING remaining + ) + SELECT remaining, true AS decremented + FROM updated + UNION ALL + SELECT remaining, false AS decremented + FROM batch_counters + WHERE batch_id = $1 + AND NOT EXISTS (SELECT 1 FROM updated) + LIMIT 1` + + module.exports = async (event, context) => { + if (event.path !== '/batch' && event.path !== '/callback') { + return respond(context, 404, 'not found') + } + + const client = new Client({ connectionString: postgresConnection() }) + try { + await client.connect() + if (event.path === '/batch') { + return await createBatch(event, context, client) + } + return await completeItem(event, context, client) + } catch (error) { + return respond(context, 502, error.message) + } finally { + await client.end() + } + } + + async function createBatch (event, context, client) { + let size + try { + size = Number(JSON.parse(requestBody(event.body)).size) + } catch (error) { + return respond(context, 400, 'expected a positive size') + } + + if (!Number.isInteger(size) || size < 1) { + return respond(context, 400, 'expected a positive size') + } + + const result = await client.query( + `INSERT INTO batch_counters (batch_id, remaining) + VALUES (gen_random_uuid()::text, $1) + RETURNING batch_id`, + [size] + ) + + return respond(context, 201, { + batch_id: result.rows[0].batch_id, + remaining: size + }) + } + + async function completeItem (event, context, client) { + const batchID = String((event.query || {}).batch_id || '').trim() + if (!batchID) { + return respond(context, 400, 'batch_id is required') + } + + const result = await client.query(decrementQuery, [batchID]) + if (result.rows.length === 0) { + return respond(context, 404, 'batch not found') + } + + const remaining = Number(result.rows[0].remaining) + const complete = remaining === 0 + if (complete && result.rows[0].decremented) { + const response = await fetch(process.env.notify_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: batchID, status: 'complete' }), + signal: AbortSignal.timeout(notifyTimeout) + }) + if (!response.ok) { + throw new Error(`notification endpoint returned ${response.status}`) + } + } + + return respond(context, 200, { + batch_id: batchID, + remaining, + complete + }) + } + + function postgresConnection () { + return fs.readFileSync(connectionSecret, 'utf8').trim() + } + + function requestBody (body) { + if (Buffer.isBuffer(body)) { + return body.toString() + } + return String(body || '') + } + + function respond (context, status, body) { + const contentType = typeof body === 'object' + ? 'application/json' + : 'text/plain; charset=utf-8' + return context + .status(status) + .headers({ 'Content-Type': contentType }) + .succeed(body) + } + ``` + + Add `pg` to `fan-in/package.json`. + +## Configure the function + +=== "Go" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-in: + lang: golang-middleware + handler: ./fan-in + image: ttl.sh/openfaas-examples/fan-in:latest + environment: + notify_url: http://gateway.openfaas:8080/async-function/printer + secrets: + - postgres-connection + ``` + +=== "Python" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-in: + lang: python3-http + handler: ./fan-in + image: ttl.sh/openfaas-examples/python-fan-in:latest + environment: + notify_url: http://gateway.openfaas:8080/async-function/printer + secrets: + - postgres-connection + ``` + +=== "Node.js" + + `stack.yaml`: + + ```yaml + version: 1.0 + provider: + name: openfaas + gateway: http://127.0.0.1:8080 + functions: + fan-in: + lang: node24 + handler: ./fan-in + image: ttl.sh/openfaas-examples/node-fan-in:latest + environment: + notify_url: http://gateway.openfaas:8080/async-function/printer + secrets: + - postgres-connection + ``` + +The `notify_url` setting is use to configure the function or HTTP endpoint +invoked after the complete batch finishes. + +## Deploy and try Fan-in + +Deploy the `printer` function and follow its logs: + +```bash +faas-cli store deploy printer +faas-cli logs printer -t +``` + +Deploy `fan-in` after completing the +[Fan-out example](/languages/patterns/fan-out/): + +```bash +faas-cli up --tag=sha +``` + +Create a counter for the three-item batch and save its generated ID: + +```bash +batch_id=$(curl -s http://127.0.0.1:8080/function/fan-in/batch \ + -H "Content-Type: application/json" \ + -d '{"size":3}' | jq -r .batch_id) +``` + +Submit the same batch from the Fan-out example, using `fan-in` as the callback: + +```bash +curl -s http://127.0.0.1:8080/function/fan-out \ + -H "Content-Type: application/json" \ + -H "X-Callback-Url: http://gateway.openfaas:8080/function/fan-in/callback?batch_id=${batch_id}" \ + -d '["one", "two", "three"]' +``` + +Each callback decrements the counter. After the third callback, the `printer` +logs receive one completion message: + +```json +{ + "batch_id": "4876cf09-a90e-40ba-9ac7-56fc87dd536b", + "status": "complete" +} +``` + +## Fan-in considerations + +* Progress must be stored in shared storage and updated atomically because + callbacks may be concurrent and may reach different `fan-in` replicas. +* To combine results, persist each callback body with its batch ID and have the + final function read them after the counter reaches zero. See the + [original Fan-out and Fan-in example](https://www.openfaas.com/blog/fan-out-and-back-in-using-functions/) + for a larger workflow that stores and combines individual results. diff --git a/mkdocs.yml b/mkdocs.yml index 9158b60b..be3d9711 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,7 +106,7 @@ markdown_extensions: - pymdownx.smartsymbols - pymdownx.superfences - pymdownx.tabbed: - alternate_style: true + alternate_style: true - pymdownx.tasklist: custom_checkbox: true - pymdownx.tilde @@ -137,6 +137,7 @@ nav: - Overview: ./languages/patterns/index.md - Director: ./languages/patterns/director.md - Fan-out: ./languages/patterns/fan-out.md + - Fan-in: ./languages/patterns/fan-in.md - Singleton: ./languages/patterns/singleton.md - Python: - Overview: ./languages/python/index.md @@ -164,7 +165,7 @@ nav: - Retries: ./openfaas-pro/retries.md - IAM & Policy: - Overview: ./openfaas-pro/iam/overview.md - - IAM Example: ./openfaas-pro/iam/example-auth0.md + - IAM Example: ./openfaas-pro/iam/example-auth0.md - Function Authentication: ./openfaas-pro/iam/function-authentication.md - GitHub Actions Federation: ./openfaas-pro/iam/github-actions-federation.md - GitLab Federation: ./openfaas-pro/iam/gitlab-federation.md