Skip to content
academia.sh

Lesson 19 / 20

Bindings and Service Connections

The same resource is reached by building a client at runtime and by a declarative binding; lines of code, startup work, where the credential sits, and the moment an error is seen are counted. The binding's scope and portability constraints are also measured.

Published Updated

In this lesson

  • Builds two access paths to the same resource and counts four things: lines of code, startup work, where the credential sits, the moment an error is seen
  • Shows the binding's scope constraint and the share of resources that never leave the client code, with a number
  • Measures the declarative binding's portability cost by the number of lines that have to change
Contents

The previous lesson used a queue and a scheduler at the edge, but never asked how access to these resources gets set up. There are two paths. In the first, the function reads the resource’s address, credential, and settings on startup, builds a client, and connects. In the second, a binding is written into the deployment definition, and the runtime hands the function a ready-made handle. This lesson reaches the same five resources both ways and counts four things: lines of code, startup work, where the credential sits, and when a misconfiguration is noticed.

How the resource gets created is not this lesson’s subject; the provider interface and resource fields were measured in the infrastructure-as-code course. The question here comes after that: how does a resource get connected to running code. The measurement again runs on the fictional measurement network; the catalog, the client, and the binding handle are set up with node as a model.

EC31. The fictional catalog has nine resources and nine types; for each type, the fields the client has to set up are given. EC32. The declarative binding supports only five of these types; the remaining four are reached by building a client at runtime. EC33. The client path opens every connection at startup, and each connection has a fixed setup time; the binding path opens no connection at startup, and the handle connects on first use. EC34. The edge runtime’s cold start baseline is 12 ms; work done at startup is added on top of that baseline. EC35. The credential is written to the process environment on the client path and passes from the config object to the client; on the binding path it never enters the code at all. The secret value that appears in the text is a fictional placeholder. EC36. At deploy time, a validator compares the manifest against the catalog and catches structural errors; there is no such check on the client path — the error is first seen on the first call to the resource.

The Same Resource, Two Access Paths

// resource.mjs — fictional resource catalog, two access paths, and a credential scanner (model).
export const TYPES = {                              // fields the client has to set up
  data_store:        { fields: ["address", "credential", "timeout", "retries", "pool"], bindable: true },
  queue:              { fields: ["address", "credential", "timeout", "batch"], bindable: true },
  object_store:       { fields: ["address", "credential", "region", "timeout"], bindable: true },
  key_value:          { fields: ["address", "credential", "timeout"], bindable: true },
  topic:              { fields: ["address", "credential", "timeout"], bindable: true },
  relational:         { fields: ["address", "credential", "timeout", "pool", "schema"], bindable: false },
  external_service:   { fields: ["address", "credential", "timeout", "retries"], bindable: false },
  directory:          { fields: ["address", "credential", "timeout"], bindable: false },
  stream:             { fields: ["address", "credential", "region", "timeout", "batch"], bindable: false },
};
export const RESOURCES = [                          // startup: connection setup ms; firstCall: deploy to first call, min
  { name: "measurement_store", type: "data_store", startup: 34, firstCall: 0.2 },
  { name: "counter_queue", type: "queue", startup: 21, firstCall: 0.2 },
  { name: "archive_store", type: "object_store", startup: 27, firstCall: 540 },
  { name: "rate_counter", type: "key_value", startup: 8, firstCall: 0.2 },
  { name: "notification_topic", type: "topic", startup: 15, firstCall: 47 },
  { name: "invoice_table", type: "relational", startup: 52, firstCall: 540 },
  { name: "field_gateway", type: "external_service", startup: 19, firstCall: 47 },
  { name: "identity_directory", type: "directory", startup: 23, firstCall: 0.2 },
  { name: "report_stream", type: "stream", startup: 30, firstCall: 540 },
];
export const BINDABLE = RESOURCES.filter((k) => TYPES[k.type].bindable);
export const COLD_BASE = 12;                        // cold start baseline, ms
export const SECRET = "FICTIONAL-SECRET-VALUE";     // fictional placeholder; not a real credential

export const clientCode = (k) => [                  // runtime client setup code
  `const ${k.name}_config = {`,
  ...TYPES[k.type].fields.map((a) => `  ${a}: env("${k.name.toUpperCase()}_${a.toUpperCase()}"),`),
  `};`,
  `const ${k.name} = makeClient("${k.type}", ${k.name}_config);`,
  `await ${k.name}.connect();`,
];
export const bindingLine = (k) => [`  { name: "${k.name}", type: "${k.type}", target: "${k.name}-prod" },`];
export const COMMON = 12;                           // helper functions' lines
export const WRAPPER = 2;                           // manifest's opening and closing lines

export function makeClient(type, config) {           // credential sits in the config object and the client
  return { type, config, connect: () => true, call: (n) => `${type}:${n}` };
}
export function bindingHandle(k) {                   // binding handle: no credential field at all
  return { name: k.name, type: k.type, call: (n) => `${k.type}:${n}` };
}
export function scan(obj, path = "root", found = []) {   // looks for a credential field in the object graph
  for (const [a, d] of Object.entries(obj)) {
    if (a === "credential") found.push(`${path}.${a}`);
    else if (d && typeof d === "object") scan(d, `${path}.${a}`, found);
  }
  return found;
}
export function rng(seed) {                          // seed is visible; same sequence run to run
  let s = seed >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
}
// code.mjs — the same five resources, two ways: lines of code and startup work counted.
import { TYPES, RESOURCES, BINDABLE, COLD_BASE, clientCode, bindingLine,
  COMMON, WRAPPER, rng } from "./resource.mjs";

const row = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 16 : 21)).join("").trimEnd());
row("resource", "type", "fields", "client lines", "binding lines");
for (const k of BINDABLE)
  row(k.name, k.type, TYPES[k.type].fields.length, clientCode(k).length, bindingLine(k).length);
const clientLines = BINDABLE.reduce((t, k) => t + clientCode(k).length, 0) + COMMON;
const bindingLines = BINDABLE.reduce((t, k) => t + bindingLine(k).length, 0) + WRAPPER;
console.log(`total: client ${clientLines} lines (${COMMON} lines of shared helper included), ` +
  `binding ${bindingLines} lines (${WRAPPER} lines of manifest wrapper included); ` +
  `ratio ${(clientLines / bindingLines).toFixed(1)}x\n`);

const startup = BINDABLE.reduce((t, k) => t + k.startup, 0);
console.log(`the client path opens ${BINDABLE.length} connections at startup: ${startup} ms; ` +
  `cold start ${COLD_BASE} ms -> ${COLD_BASE + startup} ms`);
console.log(`the binding path opens no connections at startup: 0 ms; ` +
  `cold start ${COLD_BASE} ms -> ${COLD_BASE} ms (the handle connects on first use)\n`);

const r = rng(19870504), COLD_STARTS = 40;          // forty cold starts
let clientWork = 0, bindingWork = 0, touched = 0;
for (let i = 0; i < COLD_STARTS; i++) {
  const subset = BINDABLE.filter(() => r() < 0.45);
  clientWork += startup;                            // all get set up, whether used or not
  bindingWork += subset.reduce((t, k) => t + k.startup, 0);
  touched += subset.length;
}
console.log(`${COLD_STARTS} cold starts, average resources touched ` +
  `${(touched / COLD_STARTS).toFixed(2)}/${BINDABLE.length}`);
console.log(`work done at startup: client ${clientWork} ms, binding ${bindingWork} ms ` +
  `(${(100 * (clientWork - bindingWork) / clientWork).toFixed(1)}% less)`);

const outside = RESOURCES.filter((k) => !TYPES[k.type].bindable);
const outsideLines = outside.reduce((t, k) => t + clientCode(k).length, 0) + COMMON;
console.log(`\nthe binding's scope: ${BINDABLE.length}/${RESOURCES.length} resource types; ` +
  `the remaining ${outside.length} resources (${outside.map((k) => k.type).join(", ")}) are set up with a client`);
console.log(`the mixed reality: ${outsideLines} lines of client code and ` +
  `${outside.reduce((t, k) => t + k.startup, 0)} ms of startup work stay in place`);
resource             type            fields          client lines    binding lines
measurement_store    data_store      5               9               1
counter_queue        queue           4               8               1
archive_store        object_store    4               8               1
rate_counter         key_value       3               7               1
notification_topic   topic           3               7               1
total: client 51 lines (12 lines of shared helper included), binding 7 lines (2 lines of manifest wrapper included); ratio 7.3x

the client path opens 5 connections at startup: 105 ms; cold start 12 ms -> 117 ms
the binding path opens no connections at startup: 0 ms; cold start 12 ms -> 12 ms (the handle connects on first use)

40 cold starts, average resources touched 2.20/5
work done at startup: client 4200 ms, binding 1936 ms (53.9% less)

the binding's scope: 5/9 resource types; the remaining 4 resources (relational, external_service, directory, stream) are set up with a client
the mixed reality: 45 lines of client code and 124 ms of startup work stay in place

Lines of code is the first measure. Reaching five resources with a client costs 51 lines, with a binding 7. The difference is not a style choice, it is where the work happens: on the client path every field is a line, in a binding all the fields collapse behind a single line.

The second measure is work done at startup, and it is added directly to cold start. The client path opens all five connections on startup: 105 ms, so the 12 ms baseline rises to 117 ms. The binding path opens no connection; the baseline stays at 12 ms, and connecting happens only when the handle is used. Across a fictional traffic sample of forty cold starts, a call touches 2.20 resources on average, so the binding path does 1,936 ms of connection work instead of 4,200 — 53.9 percent less. The difference is that the client path also opens connections that will never be used on that call.

The third line gives the scope. The binding recognizes five of the catalog’s nine resource types; for the relational database, external service, directory, and stream, there is no path other than building a client. These four resources keep 45 lines of client code and 124 ms of startup work in place.

Where the Credential Sits and When the Error Is Seen

// trust.mjs — where the credential sits, when the error is seen, what a move retains.
import { TYPES, RESOURCES, BINDABLE, SECRET, makeClient, bindingHandle, scan,
  clientCode, bindingLine, COMMON, WRAPPER } from "./resource.mjs";

const env = {};                                     // the credential is written to the process environment first
for (const k of BINDABLE) for (const a of TYPES[k.type].fields)
  env[`${k.name.toUpperCase()}_${a.toUpperCase()}`] = a === "credential" ? `${SECRET}-${k.name}` : `value-${a}`;
const clients = Object.fromEntries(BINDABLE.map((k) => [k.name, makeClient(k.type,
  Object.fromEntries(TYPES[k.type].fields.map((a) => [a, env[`${k.name.toUpperCase()}_${a.toUpperCase()}`]])))]));
const handles = Object.fromEntries(BINDABLE.map((k) => [k.name, bindingHandle(k)]));

const count = (obj) => (JSON.stringify(obj).match(new RegExp(SECRET, "g")) || []).length;
const row = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 15 : 28)).join("").trimEnd());
row("where the credential sits", "client", "binding");
row("deploy configuration", BINDABLE.length, BINDABLE.length);
row("process environment", Object.values(env).filter((v) => v.startsWith(SECRET)).length, 0);
row("object in code", scan(clients).length, scan(handles).length);
row("when serialized", count(clients), count(handles));
console.log(`scanned field paths: ${scan(clients).slice(0, 2).join(", ")} ...\n`);

const ERRORS = [                                    // five fictional misconfigurations
  { resource: "measurement_store", what: "field name misspelled", structural: true },
  { resource: "archive_store", what: "required field omitted", structural: true },
  { resource: "notification_topic", what: "type not in the catalog", structural: true },
  { resource: "counter_queue", what: "wrong resource name", structural: true },
  { resource: "archive_store", what: "points to a different existing target", structural: false },
];
const firstCallOf = (name) => RESOURCES.find((k) => k.name === name).firstCall;
const row2 = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 17 : 56)).join("").trimEnd());
row2("misconfiguration", "client", "binding");
for (const e of ERRORS)
  row2(`${e.resource}: ${e.what}`, `${firstCallOf(e.resource)} min later`, e.structural ? "at deploy, 0 min" : `${firstCallOf(e.resource)} min later`);
const late = ERRORS.filter((e) => !e.structural);
console.log(`client: 0/${ERRORS.length} caught at deploy, total delay ` +
  `${ERRORS.reduce((t, e) => t + firstCallOf(e.resource), 0).toFixed(1)} min, worst ${Math.max(...ERRORS.map((e) => firstCallOf(e.resource)))} min`);
console.log(`binding: ${ERRORS.length - late.length}/${ERRORS.length} caught at deploy, ` +
  `the remaining ${late.length} error surfaces ${late.reduce((t, e) => t + firstCallOf(e.resource), 0)} min later\n`);

const STUB = 3;                                     // fake handle lines written for each handle
const changedClient = BINDABLE.reduce((t, k) => t + TYPES[k.type].fields.length, 0);
const changedBinding = BINDABLE.reduce((t, k) => t + bindingLine(k).length, 0) + WRAPPER
  + BINDABLE.length * STUB;
console.log(`moving to a different service: 0 lines of code change on the client path, ` +
  `${changedClient} configuration values change`);
console.log(`on the binding path, ${changedBinding} lines change: ` +
  `${changedBinding - BINDABLE.length * STUB} manifest lines are service-specific, ` +
  `${BINDABLE.length * STUB} lines are fake handles for local testing`);
console.log(`${BINDABLE.reduce((t, k) => t + clientCode(k).length, 0) + COMMON} lines of client code ` +
  `move as-is; the binding definition does not move`);
where the credential sits   client         binding
deploy configuration        5              5
process environment         5              0
object in code              5              0
when serialized             5              0
scanned field paths: root.measurement_store.config.credential, root.counter_queue.config.credential ...

misconfiguration                                        client           binding
measurement_store: field name misspelled                0.2 min later    at deploy, 0 min
archive_store: required field omitted                   540 min later    at deploy, 0 min
notification_topic: type not in the catalog             47 min later     at deploy, 0 min
counter_queue: wrong resource name                      0.2 min later    at deploy, 0 min
archive_store: points to a different existing target    540 min later    540 min later
client: 0/5 caught at deploy, total delay 1127.4 min, worst 540 min
binding: 4/5 caught at deploy, the remaining 1 error surfaces 540 min later

moving to a different service: 0 lines of code change on the client path, 19 configuration values change
on the binding path, 22 lines change: 7 manifest lines are service-specific, 15 lines are fake handles for local testing
51 lines of client code move as-is; the binding definition does not move

The first table counts how many places the credential sits, and the two paths agree on one row: the five credentials in the deploy configuration exist on both paths, because access authority has to be defined somewhere. The split starts after that. On the client path the credential is written to the process environment, passes from there to the config object, and from there to the client; the scanning function finds five credential fields in the object graph, and the value appears five times when the object is serialized. On the binding path all four are zero: the handle object has no field called credential at all — the runtime signs the call itself. A log line, an error dump can expose five values on the client path; on the binding path there is no value left to expose.

The second table counts when the error is seen. Four of the five fictional misconfigurations are structural: a misspelled field name, a missing required field, a type not in the catalog, a wrong resource name. On the binding path, the deploy validator catches all four at minute zero. On the client path none of them are caught, because there is no declaration to validate; every error is first seen the moment it is called against the resource. Because the archive store is only called during the nightly job, its error surfaces 540 minutes after deploy — nine hours. The total delay for the five errors is 1,127.4 minutes on the client path, 540 minutes on the binding path.

The fifth error is seen late on both paths, and it draws the boundary of validation: the target name is valid, the type is correct, it has a match in the catalog — it just points to the wrong resource. The deploy validator checks form, not intent.

The Binding’s Constraint

The last three lines count portability; the binding’s second constraint is here. When the client code moves, zero lines change, only 19 configuration values change: the code recognizes the resource by its address and settings, not by the service. The binding definition, though, is service-specific: all 7 manifest lines get rewritten for the new service’s shape. On top of that, because the handle is handed over by the runtime, it has no counterpart in local testing: five handles need 15 lines of fake handles. The cost of moving is 22 lines on the binding path, zero lines and 19 values on the client path.

Read together, the two constraints show the binding’s real scope. Five of nine resources bind, four do not; for those four, 45 lines of client code, 124 ms of startup work, and credentials sitting in the process all stay in place. The binding does not take the credential out of the code — it takes it out for five of the resources’ nine. The decision handed over is establishing the connection; in exchange, 105 ms is gained at cold start, 44 lines in code, and 587 minutes in when the error is seen — but the choice of resource type is bound to the catalog, and the deployment definition to the service.

Summary

  • Reaching five resources with a client took 51 lines, with a binding 7 lines; the ratio is 7.3-fold.
  • The client path opened 5 connections at startup and raised cold start from 12 ms to 117 ms; the binding stayed at 12 ms. Across forty cold starts, a call touched an average of 2.20 resources, and connection work came to 1,936 ms instead of 4,200.
  • The credential sat in 4 places on the client path (deploy configuration, process environment, config object, serialized client); on the binding path it sat only in the deploy configuration and appeared 0 times in code.
  • 4 of 5 misconfigurations were caught at deploy time with the binding; 0 were caught with the client, and the total was seen 1,127.4 minutes later. The worst was 540 minutes, on the resource called by the nightly job.
  • The binding recognized 5 of 9 resource types; the remaining 4 resources kept 45 lines of client code and 124 ms of startup work in place.
  • The portability cost came to 22 lines with the binding (7 manifest, 15 fake handles); 0 lines of code and 19 configuration values with the client.

Next Step

Across this topic, the edge won ground one step at a time: request processing, storage, coordinated state, asynchronous work, and declarative access. Every gain came with a constraint. What remains is to look at the sum of those constraints: which limits no workaround gets the edge runtime past, and where the threshold ends past which moving a job to the edge stops making sense. The next lesson adds up these limits and closes the course.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close