Lesson 08 / 18
Synchronous Service Calls
Measuring the call crossing the boundary with real processes: the end-to-end round count growing with chain length, a distributed call finishing the same work in fewer rounds, the call amplification that gives the number of internal requests one external request turns into, and the share of entry points left unanswered when a process closes.
Contents
The previous lesson tied the boundary to a measure and left a number sitting quietly in the table: an import turning into a network call. An import edge within the same process is a function call; the same edge between two deployment units is a network call. This lesson builds that conversion not with a model but with real, separate operating-system processes, and counts the difference in rounds.
Timeouts, retries, and distributing the end-to-end budget across a chain were built and measured in The Application Layer and Service Interaction course; that arithmetic is not repeated here. The three measures here are: the end-to-end round count by chain length, the number of internal requests one external request spawns, and the share of requests left unanswered when a process goes down. A synchronous call is one where the caller waits for the response and folds the result into its own response.
The Same Work, Two Layouts
The loan-issuing workflow has four steps: recording the loan, checking membership, checking for unpaid fees, and allocating a copy. The service below is a single source file; which role it takes on comes from its command-line arguments. Every step spends the same duration, so the measured time can be divided by the number of work steps.
// service.mjs — <name> <port> <mode: parallel|sequential> <step> [successor-port...]; long-lived process import { createServer } from "node:http"; const [name, port, mode, step, ...successors] = process.argv.slice(2); if (!port) { console.log("usage: node service.mjs <name> <port> <mode> <step> [successors...]"); process.exit(1); } const WORK = 25; // duration of one work step (ms); measured time is divided by this unit let processed = 0; const call = async (p) => { // the successor's status code is checked: an error crosses the boundary const y = await fetch(`http://127.0.0.1:${p}/work`); if (y.status !== 200) throw new Error(`successor ${p} status ${y.status}`); return y.json(); }; createServer(async (request, response) => { if (request.url === "/count") { response.end(JSON.stringify({ name, processed })); return; } if (request.url === "/reset") { processed = 0; response.end("reset"); return; } processed += 1; for (let i = 0; i < Number(step); i += 1) await new Promise((c) => setTimeout(c, WORK)); try { let children = []; if (mode === "sequential") for (const p of successors) children.push(await call(p)); else children = await Promise.all(successors.map(call)); response.end(JSON.stringify({ name, step: Number(step), children })); } catch (e) { response.statusCode = 502; response.end(JSON.stringify({ name, error: e.message })); } }).listen(Number(port));
The same four steps will run in two layouts. In the split layout there are four processes: the
loan service calls the membership and catalog services, and the membership service calls the
billing service. In the monolithic layout, all four steps run sequentially in a single process;
the loan endpoint’s step value is four and there is no successor call at all. A third layout
exists for comparison: the loan service calls its two successors in sequence instead of in
parallel.
The line that checks the status code on a successor call determines the rest of the lesson. Within a process, a step’s failure propagates upward as an exception; across the boundary it is only a status code. If the caller does not check that code, the failure does not propagate — it is swallowed: the response looks successful, but the membership check inside it was never done.
Measurement
The measurement tool runs in three modes. Latency mode sends three requests to each entry point and finds the round count by dividing the median by the work-step duration; the duration measurement depends on the environment, the round count does not. Multiplier mode resets the counters, sends five requests to a single entry point, and sums the requests processed by the processes. Failure mode sends one request to each entry point and counts how many go unanswered.
SB2 — external requests are distributed equally across the four entry points. Rationale: the affected request share is the traffic weight of the entry points that depend on the service that goes down; when the weights are unknown, an equal distribution makes the share measurable. When the real distribution is known, the same calculation is done with weights.
// measure.mjs — <mode: latency|multiplier|failure> [name]; runs while service.mjs processes are up const WORK = 25; // same work step duration as service.mjs const UNIT = { billing: 8941, membership: 8942, catalog: 8943, loan: 8944, "sequential-loan": 8945, "single-process": 8946 }; const FAILURE_ENTRY = ["billing", "membership", "catalog", "loan"]; const requestUnit = async (p) => { const y = await fetch(`http://127.0.0.1:${p}/work`); if (y.status !== 200) throw new Error(`status ${y.status}`); return y.json(); }; const nodeCount = (a) => 1 + (a.children ?? []).reduce((s, x) => s + nodeCount(x), 0); const stepCount = (a) => a.step + (a.children ?? []).reduce((s, x) => s + stepCount(x), 0); const [mode, target] = process.argv.slice(2); if (mode === "latency") { console.log(`${"entry".padEnd(18)}${"processes".padStart(11)}${"network hops".padStart(14)}` + `${"work steps".padStart(12)}${"measured rounds".padStart(17)}`); let maxOverhead = 0; for (const name of Object.keys(UNIT)) { const durations = []; let tree; for (let i = 0; i < 3; i += 1) { const t = performance.now(); tree = await requestUnit(UNIT[name]); durations.push(performance.now() - t); } const median = durations.sort((a, b) => a - b)[1]; const nodes = nodeCount(tree), rounds = Math.floor(median / WORK); maxOverhead = Math.max(maxOverhead, median - rounds * WORK); console.log(`${name.padEnd(18)}${String(nodes).padStart(11)}${String(nodes - 1).padStart(14)}` + `${String(stepCount(tree)).padStart(12)}${String(rounds).padStart(17)}`); } console.log(`measured duration = rounds x ${WORK} ms + overhead; overhead in this run stayed ` + `under ${Math.ceil(maxOverhead / WORK) * WORK} ms (less than one work step)`); } else if (mode === "multiplier") { for (const name of Object.keys(UNIT)) await fetch(`http://127.0.0.1:${UNIT[name]}/reset`); const N = 5; for (let i = 0; i < N; i += 1) await requestUnit(UNIT.loan); let total = 0; for (const name of Object.keys(UNIT)) { const { processed } = await (await fetch(`http://127.0.0.1:${UNIT[name]}/count`)).json(); total += processed; if (processed > 0) console.log(` ${name.padEnd(18)}${String(processed).padStart(8)} requests`); } console.log(`${N} external requests to the loan entry point -> ${total} processed requests total, ` + `call amplification x${total / N} (${total / N - 1} internal requests per external request)`); } else if (mode === "failure") { const dropped = []; for (const name of FAILURE_ENTRY) { try { await requestUnit(UNIT[name]); } catch { dropped.push(name); } } console.log(`${target.padEnd(16)}${(dropped.join(",") || "-").padEnd(36)}` + `${dropped.length}/${FAILURE_ENTRY.length}`); }
The shell block below brings up six processes, runs the three measurements, then closes three services one by one, running failure mode after each one and restarting the closed service before moving on.
start() {
case $1 in
billing) node service.mjs billing 8941 parallel 1 & ;;
membership) node service.mjs membership 8942 parallel 1 8941 & ;;
catalog) node service.mjs catalog 8943 parallel 1 & ;;
loan) node service.mjs loan 8944 parallel 1 8942 8943 & ;;
esac
}
for s in billing membership catalog loan; do start $s; done
node service.mjs sequential-loan 8945 sequential 1 8942 8943 &
node service.mjs single-process 8946 parallel 4 &
sleep 1
node measure.mjs latency
echo
echo "call amplification:"
node measure.mjs multiplier
echo
printf '%-16s%-36s%s\n' "closed service" "unanswered entries" "affected request share"
for s in billing catalog loan; do
pkill -f "service.mjs $s" ; sleep 0.3
node measure.mjs failure $s
start $s ; sleep 0.5
done
pkill -f "node service.mjs"
entry processes network hops work steps measured rounds billing 1 0 1 1 membership 2 1 2 2 catalog 1 0 1 1 loan 4 3 4 3 sequential-loan 4 3 4 4 single-process 1 0 4 4 measured duration = rounds x 25 ms + overhead; overhead in this run stayed under 25 ms (less than one work step) call amplification: billing 5 requests membership 5 requests catalog 5 requests loan 5 requests 5 external requests to the loan entry point -> 20 processed requests total, call amplification x4 (3 internal requests per external request) closed service unanswered entries affected request share billing billing,membership,loan 3/4 catalog catalog,loan 2/4 loan loan 1/4
Chain Length Determines the Round Count
The first three rows read as the chain getting longer. A request to the billing entry point finishes in one process: zero hops, one round. The membership entry point calls billing: two processes, one hop, two rounds. The loan entry point runs four processes at once: three hops, four work steps. Chained latency becomes visible here in rounds: the end-to-end round count is the chain’s sequential step count, and the measurement returns exactly that.
The fourth and sixth rows are the lesson’s real comparison. The loan entry point finishes its four work steps in three rounds; a single process finishes the same four steps in four. The gain does not come from the split itself — it comes from the opportunity the split opens up: because the membership branch and the catalog branch do not have to wait on each other, they are called in parallel, and the end-to-end duration is the larger of the two branches, not their sum. The fifth row confirms this — the same four processes, the same three hops, but a loan service that calls its successors in sequence finishes in four rounds. A distributed call wins back a round; a sequential call does not.
The duration measurement depends on the environment, and the cost of a hop over loopback is small; the entire overhead stayed under one work step. On a real network, the same three hops are not this cheap, but the round count does not change: the round is a quantity independent of the run, and the latency budget is multiplied by rounds.
Multiplier and Affected Share
The call amplification is the second measure. Five external requests to the loan entry point make the processes run twenty requests in total: a multiplier of four, meaning three internal requests per external request. This number is a direct result of the boundary decision; the boundary-crossing import edges the previous lesson measured are exactly the edges that turn into internal requests at run time. As the number of boundary-crossing edges grows, the multiplier grows, and every service has to size its capacity not for its own traffic but for traffic multiplied by that factor.
The last table turns the new failure mode into a number. When the billing process goes down, not only its own entry point but the membership and loan entry points that depend on it also go unanswered: three of the four entry points, seventy-five percent of requests under SB2. When catalog goes down, two entry points are affected; when loan goes down, one. The affected share is the dependency closure of the service that goes down: the service at the bottom of the chain is the most expensive, because every entry point above it depends on it. In the monolithic layout this table is a single row — if the process goes down, all requests drop, but there is no such thing as partial failure.
The triple resolves like this. What got cheaper: the four work steps finish in three rounds, and every service can scale on its own; one service going down leaves the other three entry points standing. What got more expensive: three network hops per request, four processes, four times the processed-request volume, and a startup arrangement split into four. The failure mode that was born: partial failure. The system is no longer either fully up or fully down; there is a response rate that varies by entry point, and if the successor’s status code is not checked, that rate never shows up — it turns invisibly into successful responses that did incomplete work.
Summary
- The end-to-end round count is the chain’s sequential step count: the measurement gave 1, 2, and 3 rounds for the zero-, one-, and three-hop entry points respectively; overhead stayed under one work step.
- The same four work steps finish in 4 rounds in a single process and in 3 rounds across four processes; the gain is not the split itself but the parallel call the split enables — the layout that calls its successors in sequence finishes in 4 rounds.
- The call amplification is the run-time counterpart of the boundary-crossing edges: 5 external requests turned into 20 processed requests, 3 internal requests per external request.
- The affected request share when a process goes down is that service’s dependency closure: 3/4 for billing, 2/4 for catalog, 1/4 for loan.
- The boundary turns a failure from an exception into a status code; if the caller does not check the status code, the failure does not propagate — it is swallowed, and a response that looks successful contains incomplete work.
Next Step
All of these measurements lived inside a single assumption: the caller waits until the successor’s work is done. The multiplier, the round count, and the affected share are all born from that wait — because the loan service waits for the notification to be sent, the notification service going down drops the loan request. Yet not every step in a workflow has to finish for the response to be given. The next lesson builds the same workflow a second time: one of the steps on the response path stops being a call and turns into an event, processed by its consumer on its own schedule. What will be measured is also clear — the work step that drops off the response path, the contract burden that appears once the publisher and the consumer are separate deployment units, and the consistency window between an event being written and its effect becoming visible.
To keep your progress and take notes, Log in
My notes
Log in to take notes.