Lesson 05 / 18
Serverless Approach
Measuring the constraints of the event-triggered function model: the extra store calls that come from being unable to hold anything in memory between two calls, a per-call time limit splitting the workflow, and the last local calls left inside a process turning into outside calls.
Contents
The previous lesson’s five processes were long-lived: started once, staying up whether a request arrived or not, holding member records in their own memory. This lesson shrinks the deployment unit by one more step and removes long-livedness. The unit is no longer a process, it is a function that exists only when an event triggers it.
This arrangement is called serverless. The definition of the style and its initial setup cost were covered in the Architectural Styles course; what is measured here is the function model’s runtime constraints: nothing staying in memory between two calls, a time limit on every call, and the last local calls left inside a process going outside too.
AO6. The function runner is a local model: for every event it starts a new process with
node and applies a fixed time limit to that process. The model carries three properties —
memory is not shared between calls, an exceeded time limit cuts the work off mid-way, and
everything outside the function is reached over the network.
Runner and Store
State has to sit somewhere. Since functions cannot hold it, the single long-lived process is a key-value store.
// store-service.mjs — single long-lived process holding state between functions import { createServer } from "node:http"; const port = Number(process.argv[2]); if (!port) { console.log("usage: node store-service.mjs <port>"); process.exit(0); } const box = { "book:1": { status: "on_shelf" }, "member:4": { name: "Derek", openLoans: 1, limit: 5, balance: 0 }, overdue: ["o1", "o2", "o3"], processed: [] }; let calls = 0; const counts = {}; createServer(async (request, response) => { const u = new URL(request.url, "http://y"); const a = u.searchParams.get("a"); const track = () => { calls += 1; counts[a] = (counts[a] ?? 0) + 1; }; if (u.pathname === "/get") { track(); return response.end(JSON.stringify(box[a] ?? null)); } if (u.pathname === "/put") { track(); let g = ""; for await (const p of request) g += p; box[a] = JSON.parse(g); return response.end("{}"); } if (u.pathname === "/counter") { const y = { calls, member: counts["member:4"] ?? 0 }; if (u.searchParams.get("reset")) { calls = 0; for (const k in counts) delete counts[k]; } return response.end(JSON.stringify(y)); } response.statusCode = 404; response.end("{}"); }).listen(port, () => console.log(`store ${port}`));
// runner.mjs — function invoker: a new process for each event, a time limit per call import { spawnSync } from "node:child_process"; export const TIMEOUT_MS = 400; export function call(fn, ...event) { const s = spawnSync("node", [fn, ...event], { encoding: "utf8", timeout: TIMEOUT_MS }); return { cut: s.signal !== null, output: s.stdout.trim() }; }
Four Functions
Each function takes its event from the command line, does its job, prints the result, and dies. None of them can leave anything for the next call.
// function-eligibility.mjs — event-triggered function; starts with an empty memory every call const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${process.argv[2]}`)).json(); console.log(JSON.stringify({ eligible: member.openLoans < member.limit }));
// function-mark.mjs — marks the book on loan, increases the member's counter const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const [bookNo, memberNo] = process.argv.slice(2); const write = (a, d) => fetch(`http://127.0.0.1:${S}/put?a=${a}`, { method: "POST", body: JSON.stringify(d) }); const book = await (await fetch(`http://127.0.0.1:${S}/get?a=book:${bookNo}`)).json(); if (book.status !== "on_shelf") { console.log(JSON.stringify({ error: "book not on shelf" })); process.exit(0); } await write(`book:${bookNo}`, { status: "on_loan" }); const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json(); member.openLoans += 1; await write(`member:${memberNo}`, member); console.log(JSON.stringify({ status: "on_loan" }));
// function-fee.mjs — calculates the late fee and writes it to the member's balance const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const [memberNo, days] = process.argv.slice(2).map(Number); const fee = days > 0 ? days * 2 : 0; if (fee > 0) { const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json(); member.balance += fee; await fetch(`http://127.0.0.1:${S}/put?a=member:${memberNo}`, { method: "POST", body: JSON.stringify(member) }); } console.log(JSON.stringify({ fee }));
// function-notify.mjs — reads the member's name from the store and prints the notification const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const [memberNo, text] = process.argv.slice(2); const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json(); console.log(`notification -> ${member.name}: ${text}`);
// flow.mjs — loan workflow split into four function calls; each call a new process import { call } from "./runner.mjs"; const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const counter = async () => (await (await fetch(`http://127.0.0.1:${S}/counter?reset=1`)).json()); await counter(); const [bookNo, memberNo, days] = ["1", "4", "3"]; let fn = 0; const c = (name, ...event) => { fn += 1; return JSON.parse(call(name, ...event).output || "null"); }; const e = c("function-eligibility.mjs", memberNo); if (e.eligible) { c("function-mark.mjs", bookNo, memberNo); const { fee } = c("function-fee.mjs", memberNo, days); fn += 1; console.log(call("function-notify.mjs", memberNo, `book ${bookNo}`).output); console.log(`loan issued: book ${bookNo} -> member ${memberNo}, fee ${fee}`); } const s = await counter(); console.log(`function call: ${fn} new process: ${fn} store call: ${s.calls} member:4 access: ${s.member}`);
# start.sh — the single long-lived process holding state comes up, then the workflow runs node store-service.mjs 8796 >store.log 2>&1 & echo $! >store.pid for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:8796/" && break; sleep 0.2; done echo "long-lived process: 1 (store)" STORE=8796 node flow.mjs
long-lived process: 1 (store) notification -> Derek: book 1 loan issued: book 1 -> member 4, fee 6 function call: 4 new process: 4 store call: 8 member:4 access: 6
The Cost of Not Holding State
The last line carries three numbers.
New process: 4. In the previous lesson a single loan request started no new process; five processes were already up and serving the request. Here every request spawns four processes, and all four end. This is the direct consequence of the deployment unit existing only for the duration of the call.
Store call: 8. In the microservice arrangement the member record lived in the membership process’s memory; reaching the record was an in-process object read. Here no function can carry the record forward, so each function has to pull it from the store on its own call.
member:4 access: 6. The same member record crossed the network six times inside a
single loan workflow: eligibility read it, mark read and wrote it, fee read and wrote it,
notify read it. In the previous lesson the calls made to the membership service were three,
and all three worked on the same object in memory. The difference is the function model’s
most concrete cost: anything that cannot be held in memory crosses the network.
There is a second consequence tied to this. In the previous lesson fee-service kept the
daily rate in its own memory and did the calculation in-process; here the calculation is
still done in-process, but writing the result requires pulling the member record and writing
it back. The last local steps left inside a process turned into outside calls too.
Time Limit
The second constraint is the per-call time limit. A job that produces notifications for three overdue books takes 300 ms per record; the limit is 400 ms.
// function-overdue.mjs — processes overdue notifications sequentially in a single call const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const records = await (await fetch(`http://127.0.0.1:${S}/get?a=overdue`)).json(); const done = []; for (const r of records) { await new Promise((c) => setTimeout(c, 300)); done.push(r); await fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: JSON.stringify(done) }); } console.log(JSON.stringify({ processed: done.length }));
// function-overdue-single.mjs — processes one record; state is carried between calls in the store const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const records = await (await fetch(`http://127.0.0.1:${S}/get?a=overdue`)).json(); await new Promise((c) => setTimeout(c, 300)); const done = await (await fetch(`http://127.0.0.1:${S}/get?a=processed`)).json(); done.push(records[Number(process.argv[2])]); await fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: JSON.stringify(done) }); console.log(JSON.stringify({ processed: done.length }));
// duration.mjs — the same work runs first in a single call, then split across calls import { call, TIMEOUT_MS } from "./runner.mjs"; const S = process.env.STORE; if (!S) { console.log("STORE environment variable required"); process.exit(0); } const get = (path) => fetch(`http://127.0.0.1:${S}${path}`); const reset = () => fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: "[]" }); const track = async () => (await (await get("/counter?reset=1")).json()).calls; const processed = async () => (await (await get("/get?a=processed")).json()).length; await reset(); await track(); const single = call("function-overdue.mjs"); console.log(`single run: cut=${single.cut} store call=${await track()} processed=${await processed()}/3 limit=${TIMEOUT_MS} ms`); await reset(); await track(); let cut = 0; for (const i of ["0", "1", "2"]) if (call("function-overdue-single.mjs", i).cut) cut += 1; console.log(`split run : call=3 cut=${cut} store call=${await track()} processed=${await processed()}/3`);
STORE=8796 node duration.mjs
single run: cut=true store call=2 processed=1/3 limit=400 ms split run : call=3 cut=0 store call=9 processed=3/3
In the single run the job was left half-finished: one of three records was processed and the process was cut off mid-way. The cut is not an error, it is the runner’s rule — the code has no fault, the work is incomplete. What stands out here is that the cut call left a partial effect: one record had already been written to the store.
In the split version the same job spread across three calls and completed. The cost shows up in the store call count: it went from 2 to 9. The reason is that the work’s state can no longer be carried between calls — every call has to reread both the input list and what has been processed so far. When the time limit split the workflow, carrying state paid the cost of the split.
These numbers are relatively resilient to the environment for two reasons: the work per record was chosen at 300 ms, the limit at 400 ms; the quantities independent of the run are the cut call count, the processed record count, and the store call count.
# stop.sh — stops the store process kill "$(cat store.pid)" 2>/dev/null echo "stopped"
stopped
Three Columns
| Serverless | |
|---|---|
| Cheapens | The deployment unit shrank to a single file; the standing process count dropped from 5 to 1, a function does not exist outside of a call |
| Makes expensive | 4 new processes per request; 6 network accesses to the same member record; store calls went from 2 to 9 once the work was split |
| Failure mode created | The time limit cuts work off mid-way: the code has no fault, the result is incomplete, and a partial effect is left in the store |
Summary
- In the function model the deployment unit exists only for the duration of the call: a loan workflow produced 4 function calls and 4 new processes; in the previous lesson this number was zero.
- Because nothing stayed in memory between two calls, state moved to the store: the workflow made 8 store calls and reached a single member record over the network 6 times.
- The last local steps left inside a process turned into outside calls too; even though the calculation still runs in-process, writing the result requires a read and a write.
- The per-call time limit cut the work off mid-way: one of three records was processed, and the cut call left a partial effect behind.
- Splitting the work into calls kept it under the limit, but it created a state-carrying cost: store calls went from 2 to 9.
Next Step
At this point the same loan workflow has been built in five forms, and each one has written its own three columns. One question remains: given a working monolith in hand, how does a move to one of these forms happen. The next lesson takes on the migration itself: the gradual decomposition of the path, the strangler fig pattern letting the old and new paths run together for a while, and the cost of that period on the code side — shared logic held in two codebases, double maintenance cost, and reversibility.
To keep your progress and take notes, Log in
My notes
Log in to take notes.