Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close