fix(lambda): return batch failures on unhandled errors instead of dropping messages#5129
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Updates the SQS scale-up Lambda to retry the entire batch on unexpected (non-ScaleError) failures, aligning behavior with partial batch response semantics and adding/adjusting tests to verify retries and logging.
Changes:
- Return all SQS message IDs as
batchItemFailureswhenscaleUpthrows a non-ScaleError, causing SQS/Lambda to retry the batch. - Update existing tests to assert the new retry behavior for single and multi-record batches.
- Add a test asserting the updated error log message for non-
ScaleErrorfailures.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| lambdas/functions/control-plane/src/lambda.ts | Changes non-ScaleError handling to return all messages for retry and updates the log message accordingly. |
| lambdas/functions/control-plane/src/lambda.test.ts | Updates expectations to match new retry behavior and adds a log assertion test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| expect(result).toEqual({ | ||
| batchItemFailures: [{ itemIdentifier: 'message-0' }, { itemIdentifier: 'message-1' }], | ||
| }); |
There was a problem hiding this comment.
Good catch. Fixed in d949963 — switched to expect.arrayContaining with a length check.
|
Won't this feature make scale error unnecessary? |
…pping messages The scaleUpHandler catch block previously returned empty batchItemFailures for non-ScaleError exceptions, causing SQS to delete all messages in the batch. Jobs were permanently lost with no retry. Return all message IDs as batchItemFailures so SQS retries them. The redrive policy / maxReceiveCount will move persistently-failing messages to the DLQ, preventing infinite retries. Fixes github-aws-runners#5024
4ba2665 to
1439d59
Compare
|
Good question, and no —
That difference matters because Two things I noticed while digging, both arguably worse than what this PR fixes, happy to split into separate issues:
Should I also exclude Apologies for the slow reply. |
|
On Copilot's note about the order-sensitive assertion — addressed in |
|
Correction to point 1 in my earlier comment — I described the I said an unparseable body leaves What I'd conflated: the silent-drop behaviour I was describing is real, but it lives in the The parse path is still worth fixing on its own — one malformed message blocks every valid message batched with it, and Point 2 stands unchanged: Apologies for the noise — should have checked that before writing it down. |
### Problem `JSON.parse(body)` runs in the record loop at the top of `scaleUpHandler`, outside the `try`/`catch` further down. An unparseable message body therefore throws straight out of the handler. Lambda treats a thrown exception as a complete batch failure, so **nothing is deleted and the entire batch returns to the queue** after the visibility timeout. The body is malformed deterministically, so every redelivery reproduces the same `SyntaxError`. `scaleUp` is never reached, which means valid messages batched alongside the malformed one are never processed either — they are blocked as collateral damage. Two things make it worse: - `ReportBatchItemFailures` [disables Lambda's scale-down of message polling when invocations fail](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html), so the usual concurrency backoff does not apply. - `redrive_build_queue` defaults to `enabled = false`, so there is no `maxReceiveCount` cap. The only backstop is `job_queue_retention_in_seconds`, default 24h. Verified empirically against `main`: a batch of two valid records plus one malformed one rejects with `SyntaxError` and `scaleUp` is called zero times. ### Change Parse each record defensively. A malformed body is logged and reported as an individual batch item failure; the rest of the batch proceeds to `scaleUp` as normal. The malformed message then exhausts `maxReceiveCount` on its own rather than taking the batch with it. It cannot be acknowledged and discarded through the partial-failure mechanism — reporting it is the only way to single it out — so with the default configuration it expires by retention rather than moving to a DLQ. Operators who want these captured should enable `redrive_build_queue`. ### Tests Four tests covering: the invocation no longer fails, only the malformed message is reported, valid messages still reach `scaleUp`, and malformed and rejected messages combine correctly. All four fail against the current implementation. ### Note on scope This is distinct from #5129. That PR addresses the `catch` block, where a non-`ScaleError` returns an empty `batchItemFailures` — which AWS treats as complete success, so the batch is **deleted**. Opposite failure mode, different path, and worth keeping separate. I originally conflated the two in a comment on #5129 and have corrected it there. Touches the same file as #5129, so whichever lands second will need a trivial rebase.
### Problem `JSON.parse(body)` runs in the record loop at the top of `scaleUpHandler`, outside the `try`/`catch` further down. An unparseable message body therefore throws straight out of the handler. Lambda treats a thrown exception as a complete batch failure, so **nothing is deleted and the entire batch returns to the queue** after the visibility timeout. The body is malformed deterministically, so every redelivery reproduces the same `SyntaxError`. `scaleUp` is never reached, which means valid messages batched alongside the malformed one are never processed either — they are blocked as collateral damage. Two things make it worse: - `ReportBatchItemFailures` [disables Lambda's scale-down of message polling when invocations fail](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html), so the usual concurrency backoff does not apply. - `redrive_build_queue` defaults to `enabled = false`, so there is no `maxReceiveCount` cap. The only backstop is `job_queue_retention_in_seconds`, default 24h. Verified empirically against `main`: a batch of two valid records plus one malformed one rejects with `SyntaxError` and `scaleUp` is called zero times. ### Change Parse each record defensively. A malformed body is logged and reported as an individual batch item failure; the rest of the batch proceeds to `scaleUp` as normal. The malformed message then exhausts `maxReceiveCount` on its own rather than taking the batch with it. It cannot be acknowledged and discarded through the partial-failure mechanism — reporting it is the only way to single it out — so with the default configuration it expires by retention rather than moving to a DLQ. Operators who want these captured should enable `redrive_build_queue`. ### Tests Four tests covering: the invocation no longer fails, only the malformed message is reported, valid messages still reach `scaleUp`, and malformed and rejected messages combine correctly. All four fail against the current implementation. ### Note on scope This is distinct from #5129. That PR addresses the `catch` block, where a non-`ScaleError` returns an empty `batchItemFailures` — which AWS treats as complete success, so the batch is **deleted**. Opposite failure mode, different path, and worth keeping separate. I originally conflated the two in a comment on #5129 and have corrected it there. Touches the same file as #5129, so whichever lands second will need a trivial rebase.
Problem
The
scaleUpHandlercatch block returns emptybatchItemFailuresfor non-ScaleErrorexceptions. This tells SQS that all messages were processed successfully, causing them to be permanently deleted. The correspondingworkflow_jobevents are silently lost — no EC2 instances are launched, no retry occurs, and affected jobs remainqueuedin GitHub until they time out (24 hours).Any transient error (GitHub API 404, rate limit, network timeout) that isn't a
ScaleErrortriggers this path.Fix
Return all message IDs as
batchItemFailureswhen an unhandled exception occurs. SQS will retry delivery according to the queue's visibility timeout. The redrive policy /maxReceiveCountmoves persistently-failing messages to the DLQ, preventing infinite retries.Changes
lambdas/functions/control-plane/src/lambda.ts— return all messages as batch failures on non-ScaleErrorlambdas/functions/control-plane/src/lambda.test.ts— update tests to assert new behavior + add log message testImpact
Fixes #5024
Also fixes #5105