DEV Community

Cover image for What stops one queue consumer from starving your account?
Siddharth Pandey
Siddharth Pandey

Posted on

What stops one queue consumer from starving your account?

A reader's comment turned into issue #87 on my infrastructure-context tool, and that issue had three sections. I built section 1, wrote a post about it, and left sections 2 and 3 sitting in the tracker for months.

Section 2 was four sentences long:

Reserved concurrency. Unbounded scaling from a queue can exhaust account concurrency or overwhelm a downstream database. Needs GetFunctionConcurrency or the Concurrency block from GetFunction, which is one more call per function.

The reason it sat there is in the commit that finally closed it: "GetFunctionConcurrency is one call per function, which is why this sat unbuilt." One extra API call per function, multiplied across an account, on every analysis. I could not justify the cost for a check I could not even grade properly once I had the data.

Both halves of that sentence turned out to be interesting. The cost problem had a clean answer. The grading problem did not, and it is the more important of the two.

The function that quietly owns your whole account

Here is the failure, and nothing about it is visible in a handler file.

Your account has a concurrency limit. By default it is 1,000 concurrent executions across every function in a Region, and every function without a reservation shares that pool on demand. Lambda holds back 100 units that can never be reserved, so the most you can carve out at the function level is 900 by default.

Now attach a queue consumer to that pool. For a standard SQS queue, Lambda starts with five concurrent invocations and, while messages keep arriving, adds up to 300 more concurrent invocations per minute. The ceiling for a single event source mapping is 1,250 concurrent instances. Run the arithmetic: from a standing start of five, a consumer facing a real backlog reaches that ceiling in a little over four minutes.

In those four minutes, that one function takes every unreserved unit in the Region. Not "most of them" — all of them, because it will keep asking until the pool is empty. Your payment webhook, your auth token refresher, your scheduled reconciliation job: all of them are now competing for whatever is left, which is nothing, and all of them start throttling. None of those functions changed. None of them got more traffic. Somebody's queue got a backlog.

The second version of this failure is smaller and more common. Your consumer opens a database connection. One connection per execution environment is the normal shape, because the client is initialized outside the handler so it survives between invocations. At 1,250 environments that is 1,250 connections against a Postgres instance whose max_connections is 100. The queue drains fine for the first few seconds and then every invocation fails on connection exhaustion, which sends the batches back to the queue, which deepens the backlog, which makes Lambda scale harder.

Neither of these is a bug in your code. The handler is correct. The event shape is correct. The batch handling might even be correct. The thing that would prevent it lives on the function's configuration and on the event source mapping, and neither of those is in the file your editor has open.

This is the distinction the original comment drew, and it is the one I keep coming back to: the trigger shape is the first contract, delivery and retry semantics are the second. An AI assistant asked to write a queue consumer will get the first contract right — event.Records[0].body, the loop, the error handling. It has no way to know the second one, because the second one is not in your repository.

Scoping a per-function API call down to where it matters

The reason this check cost too much is arithmetic. ListFunctions returns many functions per page. GetFunctionConcurrency returns one function per call. Adding it naively means turning a handful of list calls into one call per Lambda in the account, on every single analysis.

The way out was to notice that unbounded scaling only has teeth on a particular shape of trigger:

const POLL_TRIGGERS = new Set(['sqs', 'kinesis', 'dynamodb', 'msk']);
const polled = functions.filter((f) => f.triggers.some((t) => POLL_TRIGGERS.has(t.type)));
Enter fullscreen mode Exit fullscreen mode

A poll-based source hands a function as much work as the source contains. There is no backpressure from the caller, because there is no caller — there is a queue with a depth, and Lambda's job is to drain it as fast as the limits allow. An API-triggered function does not have that shape. Its concurrency is bounded by whoever is calling the API, and the interesting failure there is a different one.

Trigger extraction already happened before this point, so the filter is free, and the extra calls are scoped to the subset of functions where the answer changes anything.

The field it writes has three states, and that is deliberate:

// null = read, no reserved concurrency configured. undefined = never read.
// The two must stay distinguishable or "unbounded" gets asserted about a
// function whose concurrency was never fetched.
reservedConcurrency?: number | null;
Enter fullscreen mode Exit fullscreen mode

GetFunctionConcurrency omits ReservedConcurrentExecutions from the response when no reservation exists. That absence is a real answer — the function is genuinely unbounded — so it becomes null. If the call throws, on an IAM denial or a transient error, the field stays undefined and the analyzer skips the function entirely:

// undefined means concurrency was never read — not evidence it is unset.
if (node.reservedConcurrency !== null) continue;
Enter fullscreen mode Exit fullscreen mode

There is a test whose whole job is to hold that line, named claims nothing when concurrency was never read. It is the same discipline that a previous check in the same file needed for ReportBatchItemFailures: a tool that occasionally accuses you of a bug you do not have gets muted, and after that it is not there on the day it is right.

The grade I could not give it

Here is where the interesting problem starts, and it is not an AWS problem.

I had the data. Every poll-triggered function in the account with no reservation was now identifiable. And I could not decide what severity to stamp on it, because the honest answer to "is this a problem?" is: I have no idea, and neither does any rule.

Go and look at a real account. Nearly every queue consumer in it has no reserved concurrency. Most of them are fine. The batch job that writes to S3 and talks to nothing else is supposed to scale freely — that is the whole reason it is on Lambda. Grading that finding high means a tool that fires on almost every triggered function in the account and is wrong about most of them. That tool gets filtered out of the report within a week, and then it is not there for the one consumer that opens database connections.

But low is wrong too, and not just because it understates the blast radius. low means a minor defect. This is not a defect of any size. It is a question about intent, and the only person who can answer it is the person who deployed the function.

So it got a severity that is not a defect grade at all:

export const SEVERITY_ORDER: Record<string, number> = { high: 3, medium: 2, low: 1, verify: 0 };
Enter fullscreen mode Exit fullscreen mode

verify sits at zero, below low. In the generated report it gets its own section, headed Verify (check intent). The recommendation text says the quiet part out loud:

If this function talks to a database or a rate-limited API, set reserved concurrency to something the downstream can absorb. If it is meant to scale freely and nothing downstream will buckle, no change is needed — this is a check on intent, not a defect.

And it is structurally incapable of breaking your build. The CI gate takes --fail-on high|medium|low and filters on the same ordering:

const threshold = SEVERITY_ORDER[failOn] ?? 3;
violations: findings.filter((f) => (SEVERITY_ORDER[f.severity] ?? 0) >= threshold),
Enter fullscreen mode Exit fullscreen mode

The lowest threshold anyone can set is low, at 1. verify is 0. There is no flag combination that turns this finding into a red pipeline, which means it can be reported on every account without anyone having to suppress it.

The grade was not invented for this check. It already existed for the S3 bucket whose public access blocking is turned off — which is either a static website origin working exactly as designed, or an incident, and no rule can tell those apart. It exists for a pipeline finding where the link between a deployed function and its source file was matched by name rather than proven from infrastructure code, so the whole chain is a reasonable guess rather than a fact.

That is three unrelated checks arriving at the same shape, which is usually a sign that the shape is real: some findings are true and still not actionable, and a tool that has no grade for that will either lie about them or drop them.

Most static analysis tooling has severity levels and nothing else. Severity answers "how bad is this if it is a problem." It has no axis for "how confident am I that this is a problem at all," so checks that score badly on confidence either get inflated into false positives or never get written. The whole class of infrastructure conditions where the correct answer is "yes, that was on purpose" falls into the gap.

What this changes when an assistant writes the consumer

The mechanical part is small. get_lambda_overview now carries the field, and only when it was actually read:

...(l.reservedConcurrency !== undefined
  ? { reservedConcurrency: l.reservedConcurrency }
  : {}),
Enter fullscreen mode Exit fullscreen mode

So an assistant about to write or modify a queue consumer can see, before writing a line, that this function polls a queue, has no reservation, and shares a pool with everything else in the account. That turns an unanswerable question into an answerable one: not "should this have reserved concurrency," which nobody can answer from the code, but "what does this function talk to downstream, and can that thing absorb 1,250 of it?" The answer to that is in the handler, which the assistant is already reading.

If the answer is no, there are two separate levers and they are easy to confuse:

Reserved concurrency is a function-level setting. It is both a floor and a ceiling — it guarantees that much concurrency to the function and prevents it from ever exceeding it. It costs nothing, and it counts against the account limit, so reserving 400 units removes 400 units from everyone else whether or not the function uses them.

Maximum concurrency (ScalingConfig.MaximumConcurrency) is a setting on the SQS event source mapping, not the function, and it takes a value between 2 and 1,000. It caps how many concurrent invocations that queue can drive, which is what you want when one function has several event sources and only one of them is the dangerous one. It also costs nothing. The two are independent, and the documented trap is setting maximum concurrency higher than the function's reserved concurrency, which just moves the failure to throttling.

For the database case specifically, the lever is whichever one puts a hard number in front of your connection pool. For the noisy-neighbour case, it is reserved concurrency on the other functions — the critical ones you want protected from the backlog, since a reservation is the only thing that carves capacity out of the shared pool.

Key takeaways

  • A poll-based trigger has no backpressure. A queue with a backlog will drive a consumer to its ceiling — up to 1,250 concurrent instances for a single SQS event source mapping — and everything in the Region without a reservation is competing for what is left.
  • The dangerous number is usually downstream, not the function. 1,250 execution environments each holding one database connection is the failure that shows up first, long before the account pool matters.
  • Scope expensive checks by shape, not by flag. A per-function API call was unaffordable across an account and perfectly affordable across the functions on a poll-based trigger, which is the only place the answer changes a decision.
  • Never let a failed read become an assertion. null means read and unset; undefined means never read. Collapsing them is how a tool ends up accusing you of a misconfiguration on a function it never managed to query.
  • Confidence is a separate axis from severity. A finding whose correct resolution is often "yes, that is intentional" needs its own grade, or it inflates into a false positive and takes the credibility of every real finding down with it.

Infrawise is open source, reads your account read-only, and exposes all of this through MCP so an assistant has the mapping's real configuration instead of an assumption. GitHub · npm

I am genuinely unsure where the line sits on this one. A verify grade that can never fail a build is easy to defend and also easy to ignore — is a finding nobody is ever forced to look at worth reporting at all, or should a tool only tell you things it is willing to block on?

Top comments (0)