From cb5d6e6630d4fc3645875f2714bd177e18035c95 Mon Sep 17 00:00:00 2001 From: Sanket Sudake Date: Wed, 22 Jul 2026 15:33:25 +0530 Subject: [PATCH] Add stateful function examples (durable key/value, no external store) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrate Fission's built-in function state — a private per-function keyspace reached over a local HTTP API with an injected scoped token, so a function keeps durable state (counters, sessions) without an external Redis or database and no credentials in the code: - nodejs/stateful-counter.js — per-user counter with compare-and-swap (no lost increments) - nodejs/stateful-session.js — self-expiring login sessions via a TTL - python/stateful_counter.py — the counter pattern in Python (stdlib only) Registered in the nodejs/python examples.json catalogs. Co-Authored-By: Claude Fable 5 --- nodejs/examples.json | 16 +++++++- nodejs/stateful-counter.js | 59 ++++++++++++++++++++++++++++++ nodejs/stateful-session.js | 64 ++++++++++++++++++++++++++++++++ python/examples.json | 9 ++++- python/stateful_counter.py | 75 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 nodejs/stateful-counter.js create mode 100644 nodejs/stateful-session.js create mode 100644 python/stateful_counter.py diff --git a/nodejs/examples.json b/nodejs/examples.json index 10a3395..f747db2 100644 --- a/nodejs/examples.json +++ b/nodejs/examples.json @@ -54,5 +54,19 @@ "path": "https://github.com/fission/examples/blob/main/nodejs/echo.js", "tag": ["node.js","Websocket"], "language": "Javascript" + }, + { + "name": "Stateful Counter", + "description": "A per-user counter using Fission's built-in function state (durable key/value with compare-and-swap), no external Redis or database", + "path": "https://github.com/fission/examples/blob/main/nodejs/stateful-counter.js", + "tag": ["node.js","State","Stateful"], + "language": "Javascript" + }, + { + "name": "Stateful Session Store", + "description": "A self-expiring login-session store using Fission's built-in function state with a TTL, no external Redis or database", + "path": "https://github.com/fission/examples/blob/main/nodejs/stateful-session.js", + "tag": ["node.js","State","Stateful","Session"], + "language": "Javascript" } -] \ No newline at end of file +] diff --git a/nodejs/stateful-counter.js b/nodejs/stateful-counter.js new file mode 100644 index 0000000..d8c3f4c --- /dev/null +++ b/nodejs/stateful-counter.js @@ -0,0 +1,59 @@ +// A per-user counter backed by Fission's built-in function state — no external +// Redis or database, and no credentials in this code. +// +// Deploy (the --state flag opts the function into its own keyspace; requires an +// install with `statestore.enabled=true` and `functionState.enabled=true`): +// +// fission function create --name counter --env nodejs --code stateful-counter.js --state +// fission route create --name counter-rt --function counter --url /counter --method GET +// curl "$FISSION_ROUTER/counter?user=alice" # -> 1, 2, 3, ... per user +// +// Fission injects FISSION_STATE_URL (the state API) and FISSION_STATE_TOKEN_PATH +// (a JSON file with this function's scoped {namespace, keyspace, token}). We talk +// to the API over plain HTTP — no SDK to install. + +const fs = require('fs'); + +function stateClient() { + const c = JSON.parse(fs.readFileSync(process.env.FISSION_STATE_TOKEN_PATH, 'utf8')); + const base = process.env.FISSION_STATE_URL; + const headers = { + 'Authorization': 'Bearer ' + c.token, + 'X-Fission-State-Namespace': c.namespace, + 'X-Fission-State-Keyspace': c.keyspace, + }; + return { + // Returns { value, version } or null when the key is absent. + async get(key) { + const r = await fetch(`${base}/v1/state/${key}`, { headers }); + if (r.status === 404) return null; + return { value: await r.text(), version: Number(r.headers.get('x-fission-state-version')) }; + }, + // Compare-and-swap: the write lands only if the key is still at ifVersion + // (use 0 for "create only"). Returns the HTTP status: 204 ok, 412 conflict. + async set(key, value, ifVersion) { + const h = { ...headers, 'If-Match': String(ifVersion) }; + const r = await fetch(`${base}/v1/state/${key}`, { method: 'PUT', headers: h, body: value }); + return r.status; + }, + }; +} + +module.exports = async function (context) { + const state = stateClient(); + const user = (context.request.query && context.request.query.user) || 'anon'; + + // get -> increment -> compare-and-swap. If another request for the same user + // incremented in between (412), read the fresh value and retry. No locks, no + // lost increments. + for (let attempt = 0; attempt < 10; attempt++) { + const cur = await state.get(user); + const next = (cur ? Number(cur.value) : 0) + 1; + const status = await state.set(user, String(next), cur ? cur.version : 0); + if (status === 204) { + return { status: 200, body: `${user}: ${next}\n` }; + } + // 412 -> someone else won the race; loop and try again. + } + return { status: 500, body: 'too much contention\n' }; +}; diff --git a/nodejs/stateful-session.js b/nodejs/stateful-session.js new file mode 100644 index 0000000..a81f475 --- /dev/null +++ b/nodejs/stateful-session.js @@ -0,0 +1,64 @@ +// A login-session store backed by Fission's built-in function state. Sessions +// self-expire via a TTL, so you never write a cleanup job — no external Redis +// or database, no credentials in this code. +// +// Deploy with a default TTL so idle sessions clean themselves up after 30 min: +// +// fission function create --name sessions --env nodejs --code stateful-session.js \ +// --state --state-keyspace user-sessions --state-ttl 30m +// fission route create --name sessions-rt --function sessions --url /session --method GET +// +// # start a session: curl "$FISSION_ROUTER/session?login=alice" -> a session id +// # check a session: curl "$FISSION_ROUTER/session?sid=" -> the user, or 401 +// +// Requires an install with `statestore.enabled=true` and +// `functionState.enabled=true`. Fission injects FISSION_STATE_URL and +// FISSION_STATE_TOKEN_PATH into the pod. + +const fs = require('fs'); +const crypto = require('crypto'); + +function stateClient() { + const c = JSON.parse(fs.readFileSync(process.env.FISSION_STATE_TOKEN_PATH, 'utf8')); + const base = process.env.FISSION_STATE_URL; + const headers = { + 'Authorization': 'Bearer ' + c.token, + 'X-Fission-State-Namespace': c.namespace, + 'X-Fission-State-Keyspace': c.keyspace, + }; + return { + async get(key) { + const r = await fetch(`${base}/v1/state/${key}`, { headers }); + return r.status === 404 ? null : await r.text(); + }, + // The keyspace default TTL (--state-ttl) applies; pass an explicit ttl + // (a Go duration like "10m") to override it for one key. + async set(key, value, ttl) { + const h = { ...headers }; + if (ttl) h['X-Fission-State-TTL'] = ttl; + await fetch(`${base}/v1/state/${key}`, { method: 'PUT', headers: h, body: value }); + }, + }; +} + +module.exports = async function (context) { + const state = stateClient(); + const q = context.request.query || {}; + + // ?login= -> mint a session id, store the session document (expires per + // the function's --state-ttl), return the id. + if (q.login) { + const sid = crypto.randomUUID(); + await state.set(sid, JSON.stringify({ user: q.login, since: Date.now() })); + return { status: 200, body: sid + '\n' }; + } + + // ?sid= -> look the session up; a missing/expired session is a 401. + if (q.sid) { + const doc = await state.get(q.sid); + if (!doc) return { status: 401, body: 'session expired or invalid\n' }; + return { status: 200, body: doc + '\n' }; + } + + return { status: 400, body: 'pass ?login= to start a session or ?sid= to check one\n' }; +}; diff --git a/python/examples.json b/python/examples.json index 4286c05..29265ba 100644 --- a/python/examples.json +++ b/python/examples.json @@ -82,5 +82,12 @@ "path": "https://github.com/fission/examples/tree/main/python/websocket", "tag": ["Python","Application","Websocket"], "language": "Python" + }, + { + "name": "Stateful Counter", + "description": "A per-user counter using Fission's built-in function state (durable key/value with compare-and-swap), no external Redis or database", + "path": "https://github.com/fission/examples/blob/main/python/stateful_counter.py", + "tag": ["Python","State","Stateful"], + "language": "Python" } -] \ No newline at end of file +] diff --git a/python/stateful_counter.py b/python/stateful_counter.py new file mode 100644 index 0000000..2eab499 --- /dev/null +++ b/python/stateful_counter.py @@ -0,0 +1,75 @@ +# A per-user counter backed by Fission's built-in function state — no external +# Redis or database, and no credentials in this code. +# +# Deploy (the --state flag opts the function into its own keyspace; requires an +# install with `statestore.enabled=true` and `functionState.enabled=true`): +# +# fission function create --name counter-py --env python --code stateful_counter.py --state +# fission route create --name counter-py-rt --function counter-py --url /counter-py --method GET +# curl "$FISSION_ROUTER/counter-py?user=alice" # -> 1, 2, 3, ... per user +# +# Fission injects FISSION_STATE_URL (the state API) and FISSION_STATE_TOKEN_PATH +# (a JSON file with this function's scoped {namespace, keyspace, token}). We talk +# to the API over plain HTTP from the standard library — no SDK to install. + +import json +import os +import urllib.error +import urllib.request + +from flask import request + + +def _client(): + with open(os.environ["FISSION_STATE_TOKEN_PATH"]) as f: + creds = json.load(f) + base = os.environ["FISSION_STATE_URL"] + headers = { + "Authorization": "Bearer " + creds["token"], + "X-Fission-State-Namespace": creds["namespace"], + "X-Fission-State-Keyspace": creds["keyspace"], + } + + def _req(method, path, body=None, extra=None): + h = dict(headers, **(extra or {})) + req = urllib.request.Request(base + path, data=body, method=method, headers=h) + try: + resp = urllib.request.urlopen(req) + return resp.status, resp.read(), resp.headers + except urllib.error.HTTPError as e: + return e.code, e.read(), e.headers + + class Client: + # Returns (value, version) or (None, 0) when the key is absent. + def get(self, key): + status, body, hdr = _req("GET", f"/v1/state/{key}") + if status == 404: + return None, 0 + return body.decode(), int(hdr.get("X-Fission-State-Version", 0)) + + # Compare-and-swap: the write lands only if the key is still at + # if_version (use 0 for "create only"). Returns the HTTP status: + # 204 ok, 412 conflict. + def set(self, key, value, if_version): + status, _, _ = _req( + "PUT", f"/v1/state/{key}", value.encode(), {"If-Match": str(if_version)} + ) + return status + + return Client() + + +def main(): + state = _client() + user = request.args.get("user", "anon") + + # get -> increment -> compare-and-swap. If another request for the same user + # incremented in between (412), read the fresh value and retry. No locks, no + # lost increments. + for _ in range(10): + value, version = state.get(user) + nxt = (int(value) if value is not None else 0) + 1 + if state.set(user, str(nxt), version) == 204: + return f"{user}: {nxt}\n" + # 412 -> someone else won the race; loop and try again. + return "too much contention\n", 500