Skip to content

fix(bb-async-job): batch 10 SQS messages per invocation with partial batch failures - #353

Open
soberm wants to merge 12 commits into
mainfrom
fix/bb-async-job-batch-size
Open

fix(bb-async-job): batch 10 SQS messages per invocation with partial batch failures#353
soberm wants to merge 12 commits into
mainfrom
fix/bb-async-job-batch-size

Conversation

@soberm

@soberm soberm commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

bb-async-job configured its SQS Lambda event source with batchSize: 1, and offered no way to configure the batching window.

Issue #, if available:

Changes

  • packages/bb-async-job: SqsEventSource default batchSize changed from 1 to 10.

  • Added maxBatchingWindowSeconds option (default 5), mapped to the event source maxBatchingWindow.

  • Partial batch failure reporting (ReportBatchItemFailures) is always enabled — it is an internal constant, not an option. It must stay on for any batchSize > 1, since without it SQS deletes every message in a batch as soon as the invocation returns, so one failing record would silently discard the rest.

  • batchSize and maxBatchingWindowSeconds remain overridable by callers; an explicitly passed value takes precedence over the default.

  • The AsyncJob constructor now range-checks both options and throws AsyncJobErrors.InvalidOption (InvalidOptionException) naming the construct, the option and the offending value. batchSize must be an integer 1–10 with no batching window, or 1–10000 once maxBatchingWindowSeconds > 0; maxBatchingWindowSeconds must be an integer 0–300. These are the limits AWS enforces when the event source mapping is created, so the failure now happens at synth instead of mid-deployment as an opaque CloudFormation error.

  • Updated README.md, DESIGN.md (the Infrastructure (CDK) event source mapping entry now documents the real mapping — always-on partial batch reporting, the maxBatchingWindowSeconds default of 5, and the ranges the constructor enforces) and regenerated API.md (npm run update:api).

  • packages/bb-agent: opts out of the new defaults on its interactive path — the internal AsyncJob is now constructed with batchSize: 1 and maxBatchingWindowSeconds: 0 at both the runtime (src/agent.ts) and CDK (src/index.cdk.ts) sites. stream() submits one job per agent turn (plus a second on HITL resume) and the caller is blocked on that job starting, so a 5s batching window would land straight on a human-facing path; batchSize: 1 also keeps one failing turn from sharing a batch with others, which matters because that handler is not idempotent. Both sites carry identical options so the runtime and CDK-synth paths provision the same event source mapping. A sweep of the remaining consumers found none that need the opt-out.

No runtime handler changes: handleEventSourceRecords in packages/core/src/lambda-handler.ts already iterates event.Records and returns { batchItemFailures }.

Validation

  • packages/bb-async-job/src/index.cdk.test.ts — 12 CDK synth tests. Event source mapping: default BatchSize: 10, FunctionResponseTypes: ['ReportBatchItemFailures'], MaximumBatchingWindowInSeconds: 5, explicit batchSize override (partial batch reporting still on), batching window override, and window 0. Option guards: batchSize 0 rejected, batchSize 11 rejected with no batching window, batchSize 500 accepted with a window (synthesizes BatchSize: 500), batchSize 10001 rejected even with a window, maxBatchingWindowSeconds 301 rejected, and a valid batchSize: 10 / window 300 combination still synthesizes.
  • packages/core/src/lambda-handler.test.ts — 3 runtime partial-batch tests over 10-record SQS batches: mixed results report only the failed messageIds, all-success returns {}, all-fail reports all 10.
  • test-apps/comprehensive/test/async-job.test.ts — the E2E result polls now run on a wall-clock deadline instead of an attempt count. The original loops spent a fixed 100 iterations of fetch+100ms sleep, so the real budget scaled inversely with environment latency: fast Sandbox reads (~66ms) collapsed it to ~16s against 15-22s of real handler latency, failing 4 tests, while Production passed only accidentally because its slower ~120ms fetches inflated the same 100 attempts to ~22s. pollForResult now loops while Date.now() < deadline on a deployed-aware budget — isDeployed ? 60s : 15s, mirroring the BLOCKS_TEST_ENV detection in async-job-status.test.ts — and the delaySeconds tests get delaySeconds * 1000 + budget so the defer window is fully covered. Assertions and test intent are unchanged, including the immediate === null checks; no production defaults were touched.
  • packages/bb-agent/src/index.cdk.test.ts — 3 new CDK synth tests pin the opt-out in the synthesized template: BatchSize: 1, MaximumBatchingWindowInSeconds: 0, and partial batch reporting still on. Confirmed non-vacuous: with the opt-out removed they fail with the inherited BatchSize: 10 / window 5. Run under --conditions=cdk, otherwise the internal BBs resolve to mocks and synthesize nothing.
  • Builds clean; bb-async-job 54/54 tests pass; bb-agent 81/81 tests pass (78 existing + 3 new); core 667/667 tests pass; comprehensive local E2E 386 pass / 0 fail (1 skipped), including all 9 AsyncJob tests.

Checklist

  • PR description included
  • Tests are changed or added
  • Relevant documentation is changed or added (and PR referenced)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

soberm added 2 commits August 13, 2026 11:36
…batch failures

The SQS event source shipped with BatchSize 1, so every queued message paid
for its own Lambda invocation — a 10x cost amplifier on high-volume queues.

Raising BatchSize alone would be unsafe: without partial batch responses SQS
treats a batch as fully handled once the invocation returns, so one failing
record silently deletes its nine healthy siblings. The three settings are
therefore changed together:

- batchSize default 1 -> 10
- reportBatchItemFailures: true (new option, default on)
- maxBatchingWindow: 5s (new maxBatchingWindowSeconds option)

The runtime handler already loops over event.Records and returns
{ batchItemFailures }, so no runtime change is needed. An explicitly passed
batchSize still wins over the new default.

Tests: new CDK synth suite pinning BatchSize, FunctionResponseTypes and
MaximumBatchingWindowInSeconds (plus the override paths), and core
lambda-handler tests proving a mixed-success 10-record batch reports exactly
the failed messages.
Review follow-up. The unsafe combination (partial batch reporting off while
batchSize > 1) was only warned about in a doc comment, so it stayed reachable
— and with the new default of 10 it was reachable by passing a single option.
It now throws at synth time; batchSize: 1 remains a legitimate way to opt out.

Also pins the exact CFN rendering of maxBatchingWindowSeconds: 0 (CDK emits an
explicit 0, it does not omit the property) instead of accepting either form,
and documents that batching leaves per-message retry/DLQ accounting unchanged.
@soberm
soberm requested a review from a team as a code owner August 13, 2026 11:37
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 082d652

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@aws-blocks/bb-async-job Patch
@aws-blocks/bb-agent Patch
@aws-blocks/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…igurable

reportBatchItemFailures must be true whenever batchSize > 1 (otherwise a single
failing record silently discards the rest of its batch), and at batchSize 1 the
two values are behaviorally equivalent for this block — so the option unlocked
no legitimate use case. It is now an internal constant, which also removes the
need for the synth-time guard that rejected the invalid combination.

batchSize and maxBatchingWindowSeconds stay configurable.
… synth

AWS rejects an SQS event source whose batchSize exceeds 10 without a batching
window (10000 with one) or whose window is outside 0-300, but it only does so
once CloudFormation creates the mapping — minutes into a deployment, with an
error that does not name the AsyncJob that caused it.

The constructor now range-checks both options and throws
InvalidOptionException naming the construct, the option and the offending
value, following the package's existing blocksError/AsyncJobErrors pattern.

Also updates the DESIGN.md event source mapping entry, which still described
`SqsEventSource(queue, { batchSize })`, to document the current mapping —
always-on partial batch reporting (required for any batchSize > 1, since
otherwise a single failing record makes SQS delete the whole batch) and the
maxBatchingWindowSeconds default of 5.
…item 3

Review follow-ups. The window is validated before batchSize because the
batchSize ceiling (10 vs 10000) is derived from it, which is easy to break by
reordering the two checks — say so. Splits the DESIGN.md event source mapping
entry into shorter sentences; no change in meaning.
soberm added 4 commits August 13, 2026 14:23
The AsyncJob E2E polls waited at most 2s (20 x 100ms) for a handler result,
but the maxBatchingWindowSeconds default of 5 means real SQS can hold a
message for up to 5s before invoking the Lambda. Five AsyncJob tests failed
on the real-AWS jobs while mock/local passed (mocks ignore the window).

Replace the ad-hoc loops with a shared pollForResult helper and named
constants documenting the batching-window dependency: 10s budget for plain
polls, delaySeconds + 5s window + 3s margin for the delayed tests.
Assertions and test intent are unchanged.
Record that pollForResult treats any falsy value as "not ready", and that the
delaySeconds tests only assert non-execution at t~=0 rather than continuously
throughout the delay window.
The new AsyncJob defaults (batchSize 10 / maxBatchingWindowSeconds 5) let SQS
hold a message for up to 5s before invoking the handler. bb-agent submits an
internal job per interactive turn (plus a second on HITL resume) and the caller
is blocked on that job starting, so the window lands directly on a
human-blocking path with no way to opt out.

Construct the internal AsyncJob with batchSize 1 and maxBatchingWindowSeconds 0
at both sites — the runtime path in agent.ts and the CDK mirror in
index.cdk.ts — so synth and runtime provision an identical event source
mapping. batchSize 1 also keeps one failing turn from sharing a batch with
others, which matters because the handler is not idempotent (it re-runs the
agent and republishes chunks).

Adds index.cdk.test.ts pinning the opt-out in the synthesized template
(BatchSize 1, MaximumBatchingWindowInSeconds 0, partial batch reporting still
on). It runs under --conditions=cdk, otherwise the internal Building Blocks
resolve to mocks and synthesize nothing.

A sweep of the other AsyncJob consumers found none that need the opt-out.
The AsyncJob E2E poll budget was an attempt count (100 x fetch+100ms),
so the real budget scaled inversely with environment latency: fast
Sandbox reads (~66ms) collapsed it to ~16s while real handler latency
is 15-22s, failing four tests. Production only passed accidentally, its
slower ~120ms fetches inflating the same 100 attempts to ~22s.

Poll on an explicit wall-clock deadline with a deployed-aware budget,
matching async-job-status.test.ts. Assertions are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant