Lesson 04 / 20
Resource and Duration Limits
Three thresholds — memory, duration, and concurrency — are applied to a set of twelve jobs, and how many jobs do not fit is counted; scanning memory tiers shows duration dropping while unit cost draws a valley, with the cheapest point landing in the middle; when the concurrency limit is shared at the account level, one function starving another is measured with the throttle rate and the lost requests.
Published Updated
In this lesson
- Applies three thresholds to a job set and counts how many jobs run into which threshold
- Scans memory tiers to show duration and unit cost moving in opposite directions, with the cheapest point in the middle
- Measures the throttle rate, the lost requests, and the queue behind them once the concurrency limit is exceeded
- Counts how sharing the limit at the account level starves one function, and the cost of a reserved share
Contents
The same numbers sat in the background across three lessons: the 900-second duration limit, the memory tiers, and the concurrency limit of 200. This lesson moves all three to the center of measurement, because this is exactly what handing off capacity management buys — the capacity decision is gone, and three numbers have arrived in its place.
The constraint shows up in two forms: some jobs do not fit the threshold at all and must be split, some jobs fit but cannot run in their cheapest or fastest form. Both are countable.
FC19. The thresholds are fictional: the memory cap is 3008 MB, the duration limit is 900 seconds, the account-level concurrency limit is 200. FC20. The measurement network’s twelve fictional jobs are listed with memory, duration, and peak concurrency values; durations are at the 1024 MB tier. FC21. Processor share is proportional to memory: one unit at 256 MB, doubling at each tier, and capping at eight at 2048 MB. FC22. If a job’s working set does not fit the chosen memory, garbage collection time is added; the coefficient is 0.15, and below 256 MB the job drops out from insufficient memory. FC23. In the concurrency model the night job is split into 900 pieces, each piece is about 245 seconds, and the deviation comes from a self-written generator, seed 20260805; field notification is 25 requests per second. FC24. A throttled notification request retries one second later, and is lost after the third attempt; prices are in fictional units.
Three Thresholds, Twelve Jobs
// measurement-network/job-set.mjs — three thresholds are applied to a job set, how many jobs do not fit (model) export const MEMORY_CAP = 3008, DURATION_LIMIT = 900, CONCURRENCY_LIMIT = 200; // MB, s, concurrent calls // The regional measurement network's fictional jobs: [name, working set MB, single run s, peak concurrency] export const JOB = [ ["reading validation", 180, 0.4, 148], ["field notification", 120, 0.3, 25], ["branch endpoint report", 300, 45, 8], ["daily summary", 512, 60, 3], ["billing generation", 900, 240, 40], ["nightly batch job (single piece)", 700, 220600, 1], ["nightly batch job (246 pieces)", 700, 897, 246], ["anomaly scan", 2200, 700, 12], ["archive compression", 1400, 1100, 6], ["monthly billing close", 2600, 1500, 4], ["meter calibration report", 3400, 420, 2], ["meter visual inspection", 5200, 95, 3], ]; export const check = ([name, mb, s, cc]) => { const exceeded = []; if (mb > MEMORY_CAP) exceeded.push("memory"); if (s > DURATION_LIMIT) exceeded.push("duration"); if (cc > CONCURRENCY_LIMIT) exceeded.push("concurrency"); return { name, mb, s, cc, exceeded }; }; // The workaround path for a job that does not fit, and its kind of cost. export const WORKAROUND = { memory: (i) => `externalize state: ${Math.ceil(i.mb / MEMORY_CAP)} pieces and ${i.mb - MEMORY_CAP} MB external read`, duration: (i) => `split: ${Math.ceil(i.s / DURATION_LIMIT)} calls and ${Math.ceil(i.s / DURATION_LIMIT) - 1} progress records`, concurrency: (i) => `throttle: ${i.cc - CONCURRENCY_LIMIT} calls wait, ${Math.ceil(i.cc / CONCURRENCY_LIMIT)} rounds`, }; if (import.meta.url === `file://${process.argv[1]}`) { console.log(`thresholds: memory ${MEMORY_CAP} MB, duration ${DURATION_LIMIT} s, concurrency ${CONCURRENCY_LIMIT}`); console.log("\n" + "job".padEnd(34) + "memory".padEnd(10) + "duration".padEnd(11) + "concurrency".padEnd(14) + "result"); const d = JOB.map(check); for (const i of d) console.log(i.name.padEnd(34) + `${i.mb} MB`.padEnd(10) + `${i.s} s`.padEnd(11) + String(i.cc).padEnd(14) + (i.exceeded.length ? `exceeds ${i.exceeded.join(" + ")}` : "fits")); const unfit = d.filter((i) => i.exceeded.length); const count = (e) => d.filter((i) => i.exceeded.includes(e)).length; console.log(`\n${unfit.length} of ${JOB.length} jobs do not fit: memory ${count("memory")}, ` + `duration ${count("duration")}, concurrency ${count("concurrency")} (a job can exceed more than one threshold)`); console.log("\n" + "unfit job".padEnd(34) + "exceeded threshold".padEnd(20) + "workaround path and cost"); for (const i of unfit) for (const e of i.exceeded) console.log(i.name.padEnd(34) + e.padEnd(20) + WORKAROUND[e](i)); }
thresholds: memory 3008 MB, duration 900 s, concurrency 200 job memory duration concurrency result reading validation 180 MB 0.4 s 148 fits field notification 120 MB 0.3 s 25 fits branch endpoint report 300 MB 45 s 8 fits daily summary 512 MB 60 s 3 fits billing generation 900 MB 240 s 40 fits nightly batch job (single piece) 700 MB 220600 s 1 exceeds duration nightly batch job (246 pieces) 700 MB 897 s 246 exceeds concurrency anomaly scan 2200 MB 700 s 12 fits archive compression 1400 MB 1100 s 6 exceeds duration monthly billing close 2600 MB 1500 s 4 exceeds duration meter calibration report 3400 MB 420 s 2 exceeds memory meter visual inspection 5200 MB 95 s 3 exceeds memory 6 of 12 jobs do not fit: memory 2, duration 3, concurrency 1 (a job can exceed more than one threshold) unfit job exceeded threshold workaround path and cost nightly batch job (single piece) duration split: 246 calls and 245 progress records nightly batch job (246 pieces) concurrency throttle: 46 calls wait, 2 rounds archive compression duration split: 2 calls and 1 progress records monthly billing close duration split: 2 calls and 1 progress records meter calibration report memory externalize state: 2 pieces and 392 MB external read meter visual inspection memory externalize state: 2 pieces and 2192 MB external read
Six of the twelve jobs do not fit: three exceed duration, two exceed memory, one exceeds the concurrency limit. The lower table writes the cost of the workaround in its own unit — because the nightly batch job takes 220,600 seconds in one piece, it is split into 246 calls and 245 progress records. The “where was I” information that is free inside a single job turns, once split, into a line item kept outside and read and written on every call.
Two rows are the same job’s two states. The night job runs into the duration limit in one piece; splitting it into 246 pieces escapes that but runs into the concurrency limit. Working around one threshold moves the job into another: 246 pieces fit the 200 limit in two rounds, and 46 calls wait for the first round.
The two jobs that exceed the memory cap take a different workaround path: externalizing state. Meter visual inspection asks for 5,200 MB, the cap is 3,008 MB, and the 2,192 MB in between is read externally on every run — trading memory access for network access.
Memory Tier Scan
Memory is not a limit on its own; it also decides the processor share. So increasing memory shortens duration, and unit cost becomes the product of two opposing forces.
// measurement-network/memory-scan.mjs — as memory grows duration drops, unit cost first drops then rises (model) import { DURATION_LIMIT } from "./job-set.mjs"; export const TIER = [128, 256, 512, 768, 1024, 1536, 2048, 3008]; // MB, fictional tiers export const BASE_MB = 256, CAP_SHARE = 8; // processor share is proportional to memory, caps at 2048 export const WORKING_SET = 900, GC_FACTOR = 0.15, LOWER_LIMIT = 256; export const SERIAL = 4000, PARALLEL = 944000; // ms: the serial and parallel portion of billing generation export const GB_S = 0.001, CALL_UNIT = 0.0005; // fictional unit export const share = (mb) => Math.min(mb / BASE_MB, CAP_SHARE); export const gc = (mb) => (mb >= WORKING_SET ? 0 : (WORKING_SET / mb - 1) ** 2 * GC_FACTOR * PARALLEL); export const run = (mb) => { if (mb < LOWER_LIMIT) return { mb, fits: false, reason: "not enough memory" }; const duration = (SERIAL + PARALLEL / share(mb) + gc(mb)) / 1000; return { mb, fits: duration <= DURATION_LIMIT, reason: duration > DURATION_LIMIT ? "duration limit" : "", duration, share: share(mb), gc: gc(mb) / 1000, cost: (mb / 1024) * duration * GB_S + CALL_UNIT }; }; if (import.meta.url === `file://${process.argv[1]}`) { console.log(`billing generation: working set ${WORKING_SET} MB, parallel portion ` + `${PARALLEL / 1000} s (at ${BASE_MB} MB), processor share cap ${CAP_SHARE}`); console.log("\n" + "memory".padEnd(10) + "processor share".padEnd(17) + "garbage collection".padEnd(20) + "duration".padEnd(12) + "unit cost".padEnd(16) + "result"); const s = TIER.map(run); for (const x of s) console.log(`${x.mb} MB`.padEnd(10) + (x.fits === false && !x.duration ? "—".padEnd(17) + "—".padEnd(20) + "—".padEnd(12) + "—".padEnd(16) : x.share.toFixed(2).padEnd(17) + `${x.gc.toFixed(1)} s`.padEnd(20) + `${x.duration.toFixed(1)} s`.padEnd(12) + x.cost.toFixed(4).padEnd(16)) + (x.fits ? "fits" : x.reason)); const fitting = s.filter((x) => x.fits); const cheapest = fitting.reduce((a, b) => (b.cost < a.cost ? b : a)); const fastest = fitting.reduce((a, b) => (b.duration < a.duration ? b : a)); const largest = fitting[fitting.length - 1]; console.log(`\ncheapest tier ${cheapest.mb} MB: ${cheapest.duration.toFixed(1)} s, ${cheapest.cost.toFixed(4)} units`); console.log(`fastest tier ${fastest.mb} MB: ${(cheapest.duration / fastest.duration).toFixed(2)}x faster, ` + `${((fastest.cost / cheapest.cost - 1) * 100).toFixed(1)}% more expensive`); console.log(`largest tier ${largest.mb} MB: same duration, ` + `${((largest.cost / cheapest.cost - 1) * 100).toFixed(0)}% more expensive — because the processor share hits its cap`); }
billing generation: working set 900 MB, parallel portion 944 s (at 256 MB), processor share cap 8 memory processor share garbage collection duration unit cost result 128 MB — — — — not enough memory 256 MB 1.00 896.1 s 1844.1 s 0.4615 duration limit 512 MB 2.00 81.3 s 557.3 s 0.2792 fits 768 MB 3.00 4.2 s 322.8 s 0.2426 fits 1024 MB 4.00 0.0 s 240.0 s 0.2405 fits 1536 MB 6.00 0.0 s 161.3 s 0.2425 fits 2048 MB 8.00 0.0 s 122.0 s 0.2445 fits 3008 MB 8.00 0.0 s 122.0 s 0.3589 fits cheapest tier 1024 MB: 240.0 s, 0.2405 units fastest tier 2048 MB: 1.97x faster, 1.7% more expensive largest tier 3008 MB: same duration, 49% more expensive — because the processor share hits its cap
The duration column falls in a single direction: from 1,844 seconds to 122 seconds. The cost column falls and then rises: it drops from 0.4615 to 0.2405, then climbs to 0.3589; the cheapest tier, 1024 MB, sits in the middle of the table. The reason for the rise on the right sits in the last row — the processor share caps at 2048 MB, and choosing 3008 MB raises the bill 49% without shortening duration at all.
The reason for the rise on the left is different. At 256 MB the job’s working set does not fit in memory, and garbage collection adds 896 seconds; the job takes 1,844 seconds and falls outside the duration limit. This row shows the two thresholds are linked: dropping the memory tier throws the job into the duration limit. At 512 MB the same effect drops to 81 seconds and the job fits, but it is both slower and more expensive than 1024 MB.
The last rows say the decision is not single: 2048 MB is 1.97x faster than 1024 MB and only 1.7% more expensive. For work where latency matters this trade is cheap; for a night job no one is waiting on, it is unnecessary. The same table suggests two different rows for two different jobs.
The Shared Limit and Throttling
Duration and memory are the job’s own decisions. Concurrency is not: the limit sits at the account level, and every function shares it.
// measurement-network/concurrency.mjs — the account-level shared concurrency limit and throttling (model) import { CONCURRENCY_LIMIT } from "./job-set.mjs"; export const STEP_MS = 40, WINDOW_S = 2400, STEPS = WINDOW_S * 1000 / STEP_MS; export const PIECE = 900, PIECE_S = 245, DEVIATION = 20; // night job: 900 pieces, ~245 s export const NOTIFICATION_STEPS = 8, RETRY = 25; // 300 ms processing, retry 1 s later export const rng = (t) => { let s = t >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; }; export const run = (reserve) => { const cap = CONCURRENCY_LIMIT - reserve, r = rng(20260805); const doneNight = new Int32Array(STEPS + 20000), doneNotif = new Int32Array(STEPS + 20000); const wait = [new Int32Array(STEPS + 200), new Int32Array(STEPS + 200), new Int32Array(STEPS + 200)]; let nightActive = 0, notifActive = 0, remaining = PIECE, throttled = 0, lost = 0, success = 0, delay = 0; let nightEnd = -1, peakNight = 0, peakNotif = 0; for (let a = 0; a < STEPS; a++) { nightActive -= doneNight[a]; notifActive -= doneNotif[a]; for (let d = 0; d < 3; d++) { const n = (d === 0 ? 1 : 0) + wait[d][a]; for (let i = 0; i < n; i++) { if (nightActive + notifActive < CONCURRENCY_LIMIT) { notifActive++; doneNotif[a + NOTIFICATION_STEPS]++; success++; delay += d * RETRY * STEP_MS; } else { throttled++; if (d < 2) wait[d + 1][a + RETRY]++; else lost++; } } } while (remaining > 0 && nightActive + notifActive < CONCURRENCY_LIMIT && nightActive < cap) { const s = PIECE_S + (r() * 2 - 1) * DEVIATION; nightActive++; doneNight[a + Math.round(s * 1000 / STEP_MS)]++; remaining--; } if (nightActive > peakNight) peakNight = nightActive; if (notifActive > peakNotif) peakNotif = notifActive; if (remaining === 0 && nightActive === 0 && nightEnd < 0) nightEnd = a; } return { reserve, cap, nightEnd: nightEnd * STEP_MS / 1000, rounds: Math.ceil(PIECE / cap), throttled, lost, success, incoming: STEPS, throttleRate: throttled / STEPS, peakNight, peakNotif }; }; if (import.meta.url === `file://${process.argv[1]}`) { console.log(`account limit ${CONCURRENCY_LIMIT}; night job ${PIECE} x ~${PIECE_S} s, ` + `notification ${1000 / STEP_MS}/s x ${NOTIFICATION_STEPS * STEP_MS} ms`); console.log("\n" + "arrangement".padEnd(24) + "night cap".padEnd(13) + "night ends".padEnd(13) + "rounds".padEnd(9) + "throttled notifications".padEnd(25) + "throttle rate".padEnd(15) + "lost".padEnd(12) + "peak slots (night / notification)"); const p = run(0), y = run(40); for (const s of [p, y]) console.log((s.reserve ? `reserved share ${s.reserve}` : "shared").padEnd(24) + String(s.cap).padEnd(13) + `${s.nightEnd.toFixed(0)} s`.padEnd(13) + String(s.rounds).padEnd(9) + String(s.throttled).padEnd(25) + `${(s.throttleRate * 100).toFixed(1)}%`.padEnd(15) + String(s.lost).padEnd(12) + `${s.peakNight} / ${s.peakNotif}`); console.log(`\nthe reserved share delays the night job by ${(y.nightEnd - p.nightEnd).toFixed(0)} s; ` + `in return it prevents ${p.throttled} throttling events and ${p.lost} lost notifications — ` + `of ${p.incoming} incoming requests the throttle rate is ${(p.throttled / p.incoming * 100).toFixed(1)}%`); }
account limit 200; night job 900 x ~245 s, notification 25/s x 320 ms arrangement night cap night ends rounds throttled notifications throttle rate lost peak slots (night / notification) shared 200 1254 s 5 14814 24.7% 4923 199 / 14 reserved share 40 160 1496 s 6 0 0.0% 0 160 / 8 the reserved share delays the night job by 242 s; in return it prevents 14814 throttling events and 4923 lost notifications — of 60000 incoming requests the throttle rate is 24.7%
In the shared arrangement, the night job holds 199 slots. 24.7% of notification requests are throttled, 8.2% are lost after three attempts: 4,923 notifications are never sent. The night job sees no error during this. Starving is a one-sided event; it shows up in the victim’s log, not the offender’s.
The reserved share fixes this and writes its cost openly: once the night cap drops to 160, the job finishes in six rounds instead of five and takes 1,496 seconds instead of 1,254. 242 seconds of delay is the price of 14,814 throttling events and 4,923 lost notifications. The reserved share does not create new capacity; it divides up what already exists, and it forces that division to be made once, in advance.
Summary
- Six of the twelve jobs do not fit the thresholds: three exceed duration, two exceed memory, one exceeds the concurrency limit.
- Working around one threshold moves the job into another: the night job exceeds the duration limit in one piece, runs into the concurrency limit once split into 246 pieces, and pays 245 progress records.
- In the memory scan, duration drops from 1,844 seconds to 122 seconds while cost draws a valley; the cheapest tier is 1024 MB, and 3008 MB raises the bill 49% without shortening duration at all.
- At 256 MB the working set does not fit, so garbage collection adds 896 seconds and the job falls outside the duration limit: dropping the memory tier throws the job into a second threshold.
- Because the concurrency limit is shared at the account level, the night job holds 199 slots; 24.7% of notification requests are throttled, 8.2% are lost, and the night job never sees any of it.
- The reserved share drops throttling to zero and writes the cost onto the night job: six rounds instead of five, 242 seconds more.
Next Step
This lesson measured the three walls around the function, and every measurement carried a silent assumption: the code being measured is the code running.
But the change that splits the night job into 246 pieces, or the setting that pulls the memory tier from 512 to 1024, gets published on some day, and on that day two versions exist side by side. Which version traffic goes to, and whether settings travel along with the version, calls for a separate measurement. The next lesson measures the function’s versioning and how traffic is split across versions.
To keep your progress and take notes, Log in
My notes
Log in to take notes.