Skip to content

fix(lambda): return batch failures on unhandled errors instead of dropping messages#5129

Open
vegardx wants to merge 2 commits into
github-aws-runners:mainfrom
vegardx:fix/batch-item-failures-on-unhandled-errors
Open

fix(lambda): return batch failures on unhandled errors instead of dropping messages#5129
vegardx wants to merge 2 commits into
github-aws-runners:mainfrom
vegardx:fix/batch-item-failures-on-unhandled-errors

Conversation

@vegardx

@vegardx vegardx commented May 26, 2026

Copy link
Copy Markdown
Contributor

Problem

The scaleUpHandler catch block returns empty batchItemFailures for non-ScaleError exceptions. This tells SQS that all messages were processed successfully, causing them to be permanently deleted. The corresponding workflow_job events are silently lost — no EC2 instances are launched, no retry occurs, and affected jobs remain queued in GitHub until they time out (24 hours).

Any transient error (GitHub API 404, rate limit, network timeout) that isn't a ScaleError triggers this path.

Fix

Return all message IDs as batchItemFailures when an unhandled exception occurs. SQS will retry delivery according to the queue's visibility timeout. The redrive policy / maxReceiveCount moves persistently-failing messages to the DLQ, preventing infinite retries.

} else {
  logger.error(
    `Error processing batch (size: ${sqsMessages.length}): ${(e as Error).message}, returning batch for retry`,
    { error: e },
  );
  batchItemFailures.push(...sqsMessages.map(({ messageId }) => ({ itemIdentifier: messageId })));
}

Changes

  • lambdas/functions/control-plane/src/lambda.ts — return all messages as batch failures on non-ScaleError
  • lambdas/functions/control-plane/src/lambda.test.ts — update tests to assert new behavior + add log message test

Impact

  • Non-ScaleError exceptions → messages retried via SQS (previously: silently dropped)
  • ScaleError path → unchanged
  • DLQ catches persistently-failing messages (no infinite retry loop)

Fixes #5024
Also fixes #5105

@vegardx
vegardx requested a review from a team as a code owner May 26, 2026 20:19
Copilot AI review requested due to automatic review settings May 26, 2026 20:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 batchItemFailures when scaleUp throws 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-ScaleError failures.

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.

Comment on lines +221 to +223
expect(result).toEqual({
batchItemFailures: [{ itemIdentifier: 'message-0' }, { itemIdentifier: 'message-1' }],
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in d949963 — switched to expect.arrayContaining with a length check.

@edersonbrilhante

Copy link
Copy Markdown
Contributor

Won't this feature make scale error unnecessary?

vegardx added 2 commits July 21, 2026 11:06
…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
@vegardx
vegardx force-pushed the fix/batch-item-failures-on-unhandled-errors branch from 4ba2665 to 1439d59 Compare July 21, 2026 09:30
@vegardx

vegardx commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Good question, and no — ScaleError still carries something this fallback can't.

toBatchItemFailures() returns a subset: messages.slice(0, failedInstanceCount). Combined with the ascending retryCounter sort in lambda.ts, it retries the N least-retried messages and ACKs the rest. The fallback here returns everything.

That difference matters because scaleUp calls createRunners per runner group. A ScaleError from group 3 aborts after groups 1 and 2 already launched instances — returning every message ID would make SQS redeliver those and create a second runner for jobs that already have one. So I'd keep ScaleError as the precise path and treat this only as the net for unclassified errors.

Two things I noticed while digging, both arguably worse than what this PR fixes, happy to split into separate issues:

  1. JSON.parse(record.body) in lambda.ts throws before sqsMessages is populated, so batchItemFailures comes back empty and the whole batch is silently dropped — the exact failure mode Scale-up Lambda silently drops entire SQS batch on non-ScaleError exceptions #5024 describes, on a path this PR doesn't cover.
  2. redrive_build_queue defaults to enabled = false, so for most deployments a genuinely poison message now retries until retention expires instead of dropping once. Worth calling out in the docs if we merge this as-is.

Should I also exclude rejectedMessageIds from the returned set? Those are deliberately rejected and permanently unprocessable, so retrying them is pure waste.

Apologies for the slow reply.

@vegardx

vegardx commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

On Copilot's note about the order-sensitive assertion — addressed in test: use order-insensitive assertion for batchItemFailures, which is on the branch. The comparison no longer depends on the relative order of equal-retryCounter messages.

@vegardx

vegardx commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Correction to point 1 in my earlier comment — I described the JSON.parse path wrongly, and in a way that undersold this PR rather than overselling it.

I said an unparseable body leaves batchItemFailures empty and silently drops the batch. That's not what happens. JSON.parse is outside the try, so it throws straight out of the handler, and Lambda treats a thrown exception as a complete batch failure — nothing is deleted and the whole batch is redelivered. Poison pill, not silent loss. I've verified that empirically against main rather than by reading.

What I'd conflated: the silent-drop behaviour I was describing is real, but it lives in the catch block — a non-ScaleError pushes nothing to batchItemFailures and returns { batchItemFailures: [] }, which AWS treats as complete success and deletes every message in the batch. That is exactly what this PR fixes. So the two paths fail in opposite directions, and my example belonged to this PR's scope rather than outside it.

The parse path is still worth fixing on its own — one malformed message blocks every valid message batched with it, and ReportBatchItemFailures disables Lambda's usual polling scale-down on failed invocations, so there's no backoff cushion. That's #5215, kept separate since it's a different failure mode. It touches the same file, so whichever of the two lands second will need a small rebase.

Point 2 stands unchanged: redrive_build_queue defaults to enabled = false, so on this PR's path a genuinely unprocessable message retries until retention expires instead of being dropped once. Still worth a docs note.

Apologies for the noise — should have checked that before writing it down.

edersonbrilhante pushed a commit that referenced this pull request Jul 21, 2026
### 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.
Copilot AI pushed a commit that referenced this pull request Jul 22, 2026
### 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants