ecs-batch
@middy/ecs-batch is a runtime wrapper, not a middleware. It lets you take a Middy handler that targets a Lambda batch event source mapping (SQS, Kinesis, DynamoDB Streams, MSK / SelfManagedKafka, Amazon MQ for ActiveMQ, Amazon MQ for RabbitMQ) and run it as a long-running consumer on AWS ECS/Fargate.
The runner pulls records from the event source, builds the same batch event shape Lambda would deliver, invokes your handler with (event, context), then uses the response ({ batchItemFailures: [...] }) to acknowledge successful records natively (DeleteMessageBatch for SQS, commitOffsetsIfNecessary for Kafka, channel.ack for RabbitMQ, etc.). Stream sources (Kinesis, DynamoDB Streams) advance their iterator implicitly — checkpointing is your handler's responsibility.
By default the runner forks one node:cluster worker per CPU core (availableParallelism()), restarts crashed workers, and on SIGTERM aborts in-flight polls, lets the in-flight handler invocation finish, then exits within gracefulShutdownMs. This makes Fargate Spot reclamation (2-minute SIGTERM warning) safe by default.
Install
npm install --save @middy/ecs-batch Then install only the client(s) for the source(s) you poll:
npm install --save @aws-sdk/client-sqs # for pollSqs
npm install --save @aws-sdk/client-kinesis # for pollKinesis
npm install --save @aws-sdk/client-dynamodb-streams # for pollDynamoDBStreams
npm install --save kafkajs # for pollKafka
npm install --save stompit # for pollAmq
npm install --save amqplib # for pollRmq Options
handler(function) (required): Your Middy handler, e.g.middy(lambdaHandler).use(eventBatchResponse()).poller(object) (required): A poller created by one of the source modules (see below). Exactly one poller per runner — to consume from multiple sources, run multiple ECS tasks.workers(integer): Number of forked worker processes. Defaults toavailableParallelism(). Set to1for shard-based sources (Kinesis, DynamoDB Streams) where one consumer per shard is required; scale by running one ECS task per shard.timeout(integer, ms): Wall-clock budget per batch exposed viacontext.getRemainingTimeInMillis. Defaults to60000.gracefulShutdownMs(integer, ms): OnSIGTERM, the runner aborts polls and waits up to this many ms for the in-flight handler + acknowledge to drain before forcingprocess.exit(1). Defaults to110000(just under Fargate Spot's 120 s reclamation budget).onError(err, event)(function, optional): Called when the handler throws oracknowledgethrows. Use it to surface failures to your logger or APM.
NOTES:
- The runner is silent. Wire batch logging via Middy middleware (
input-output-logger,error-logger). - When the handler throws, the runner skips
acknowledgeand lets the source's native retry path take over (SQS visibility timeout requeues, Kafka offset stays uncommitted, RabbitMQ leaves the message unacked). - When the handler returns
{ batchItemFailures: [...] }, the runner acknowledges the successful records only. Failed records are left for native redelivery. - For Kafka, offsets are committed sequentially per partition; on the first failed offset the runner stops committing further offsets in that batch so the failed message and everything after it redeliver in order.
- The ECS task metadata endpoint (
$ECS_CONTAINER_METADATA_URI_V4) is fetched once in the primary process; values are propagated to workers via env vars and made available oncontext.invokedFunctionArn.
Workers and stateful sources
workers = availableParallelism() is the right default for queue-style sources where competing consumers add throughput:
- SQS — the queue is concurrent-safe.
- Kafka with a consumer group — kafkajs auto-balances partitions across worker processes.
- RabbitMQ classic queues with competing consumers (work queue pattern).
- ActiveMQ queues with
client-individualack.
It is wrong for shard-based sources where exactly-one consumer per shard is required:
- Kinesis Data Streams — set
workers: 1and run one ECS task per shard. - DynamoDB Streams — same.
Sample usage: SQS
import middy from '@middy/core'
import { ecsBatchRunner } from '@middy/ecs-batch'
import { pollSqs } from '@middy/ecs-batch/pollSqs'
import eventBatchParser from '@middy/event-batch-parser'
import parseJson from '@middy/event-batch-parser/parseJson'
import eventBatchHandler from '@middy/event-batch-handler'
import eventBatchResponse from '@middy/event-batch-response'
const recordHandler = async (record) => {
// record.body is parsed JSON thanks to eventBatchParser
await processOrder(record.body)
}
const lambdaHandler = (event, context) =>
eventBatchHandler(recordHandler)(event, context)
const handler = middy()
.use(eventBatchParser({ body: parseJson() }))
.use(eventBatchResponse())
.handler(lambdaHandler)
await ecsBatchRunner({
handler,
poller: pollSqs({
queueUrl: 'https://sqs.us-east-1.amazonaws.com/111111111111/orders',
maxNumberOfMessages: 10,
waitTimeSeconds: 20,
}),
}) The same handler runs unmodified on Lambda when wired to an SQS event source mapping with ReportBatchItemFailures.
Sample usage: Kinesis (one task per shard)
import { ecsBatchRunner } from '@middy/ecs-batch'
import { pollKinesis } from '@middy/ecs-batch/pollKinesis'
await ecsBatchRunner({
handler,
workers: 1, // required: one consumer per shard
poller: pollKinesis({
streamName: 'events',
shardId: process.env.KINESIS_SHARD_ID, // injected per task
streamArn: 'arn:aws:kinesis:us-east-1:111:stream/events',
awsRegion: 'us-east-1',
shardIteratorType: 'LATEST',
}),
}) Sample usage: Kafka (MSK or self-managed)
import { ecsBatchRunner } from '@middy/ecs-batch'
import { pollKafka } from '@middy/ecs-batch/pollKafka'
await ecsBatchRunner({
handler,
poller: pollKafka({
brokers: ['b-1.cluster.kafka.us-east-1.amazonaws.com:9092'],
groupId: 'orders-consumer',
topics: ['orders'],
eventSourceArn: 'arn:aws:kafka:us-east-1:111:cluster/...',
// selfManaged: true // emits "SelfManagedKafka" eventSource instead of "aws:kafka"
}),
}) Sample usage: RabbitMQ
import { ecsBatchRunner } from '@middy/ecs-batch'
import { pollRmq } from '@middy/ecs-batch/pollRmq'
await ecsBatchRunner({
handler,
poller: pollRmq({
url: 'amqps://user:pass@b-xyz.mq.us-east-1.amazonaws.com:5671',
queue: 'orders',
vhost: '/',
prefetch: 20,
batchSize: 10,
batchWindowMs: 1000,
}),
}) Pollers
Each poller is a factory that returns { source, poll, acknowledge } and is exported from a subpath of the package so its peer dependency is only loaded when used.
pollSqs(options)
Long-polls SQS via ReceiveMessageCommand. Acknowledges by DeleteMessageBatch on records not in batchItemFailures, chunked to 10 per request.
queueUrl(string) (required)client(SQSClient): Inject your own client (e.g. with custom region/credentials).maxNumberOfMessages(1–10): Defaults to10.waitTimeSeconds(0–20): Long-poll wait. Defaults to20.visibilityTimeout(integer, seconds): Override per-batch.eventSourceArn,awsRegion: Defaults are derived fromqueueUrl.
pollKinesis(options)
GetShardIterator once, then loop GetRecordsCommand advancing NextShardIterator. Acknowledge is a no-op; the iterator advances implicitly.
streamName(string) (required)shardId(string) (required) — pass via env var, run one task per shard.streamArn,awsRegion: For event ARN/region fields.client(KinesisClient)shardIteratorType:"LATEST"(default),"TRIM_HORIZON","AT_SEQUENCE_NUMBER","AFTER_SEQUENCE_NUMBER","AT_TIMESTAMP".startingSequenceNumber,timestamp: For checkpoint resumption.limit(1–10000): Defaults to1000.pollingDelay(ms): Sleep between emptyGetRecordsresponses. Defaults to1000.
pollDynamoDBStreams(options)
Same shard-iterator pattern as Kinesis, against DynamoDBStreamsClient.
streamArn(string) (required)shardId(string) (required)client,shardIteratorType,sequenceNumber,limit,pollingDelay,awsRegion.
pollKafka(options)
Connects a kafkajs consumer, subscribes to topics, runs eachBatch with partitionsConsumedConcurrently: 1 and autoCommit: false. Bridges kafkajs's push-mode callback to the runner's pull loop. On acknowledge, commits offsets up to (but not including) the first failed offset per partition.
brokers(string[]) (required)groupId(string) (required)topics(string[]) (required)clientId(string)fromBeginning(boolean): Defaults tofalse.client(Kafka),consumer(Consumer): Inject pre-constructed instances.ssl(boolean)eventSourceArn(string)selfManaged(boolean): Whentrue, emitseventSource: "SelfManagedKafka"instead of"aws:kafka".
pollAmq(options)
Subscribes to an ActiveMQ queue over STOMP via stompit, with client-individual ack mode. Buffers up to batchSize messages within a batchWindowMs window before yielding a batch. acks successful messages and nacks failed ones based on the response.
connectOptions(object) (required) — passed through tostompit.connect.destination(string) (required) — e.g."/queue/orders".ackMode:"client-individual"(default) or"client".batchSize(integer): Defaults to10.batchWindowMs(integer): Defaults to1000.eventSourceArn(string)
pollRmq(options)
Connects to RabbitMQ via amqplib, sets prefetch, consumes the queue. Buffers up to batchSize messages within a batchWindowMs window. On acknowledge, channel.ack on success, channel.nack(msg, false, true) (requeue) on failure.
queue(string) (required)url(string): AMQP connection URL. Required unlessconnectionis injected.vhost(string): Used to build thermqMessagesByQueuekey ("queue::vhost").prefetch(integer): Defaults tobatchSize * 2.batchSize,batchWindowMsconnection,channel: Inject pre-constructed instances.eventSourceArn(string)
Event shapes
Each poller produces the exact shape that @middy/event-batch-parser and @middy/event-batch-response already understand:
| Poller | eventSource | Container |
|---|---|---|
pollSqs | "aws:sqs" | Records[] |
pollKinesis | "aws:kinesis" | Records[] (each with .kinesis.data base64) |
pollDynamoDBStreams | "aws:dynamodb" | Records[] (each with .dynamodb) |
pollKafka | "aws:kafka" or "SelfManagedKafka" | records["topic-partition"][] |
pollAmq | "aws:amq" | messages[] |
pollRmq | "aws:rmq" | rmqMessagesByQueue["queue::vhost"][] |
This means your handler is portable: pair it with a Lambda event source mapping today, lift it onto ECS tomorrow, no code changes.
Fargate Spot
Spot tasks receive SIGTERM ~2 minutes before reclamation. The runner installs a single AbortController per worker and on SIGTERM:
- Aborts the in-flight
client.send/consume/subscribeso the poll loop exits at its next iteration. - Awaits the in-flight handler +
acknowledge(still committing successful work). - Exits
0if drained withingracefulShutdownMs, else1.
For shard-based pollers (Kinesis, DynamoDB Streams) you should also persist your last-processed SequenceNumber from your handler so the next task instance resumes via startingSequenceNumber / sequenceNumber.
Last updated: