Postgres + SQS On AWS
End-to-end Runlane deploy on AWS — RDS Postgres, SQS, Lambda, Fargate, plus the maintenance tick.
@runlane/example-aws is a deployable reference app showing the production lane composed from @runlane/postgres-storage and @runlane/transport-sqs.
It deploys RDS Postgres, three SQS wakeup queues, two dead-letter queues, two SQS-Lambda consumers, an EventBridge cron Lambda for runlane.tick(), an HTTP trigger Lambda, and a Fargate service running createSqsDeliveryConsumer. Prisma owns the application data layer.
The full source lives in examples/aws/. This page explains the architecture and the design choices behind it.
Topology
| Component | Role |
|---|---|
RDS Postgres db.t4g.micro | Durable Runlane storage + app data, publicly reachable for tear-down demo convenience |
SQS queue Emails (standard) | Wakeups consumed by the SQS-Lambda handler |
SQS queue Heavy (standard) | Wakeups consumed by the Fargate long-running consumer |
SQS queue Ordered (FIFO) | Wakeups consumed by the FIFO SQS-Lambda handler with fifo: true semantics |
SQS queue Dlq (standard) | Shared dead-letter target for Emails and Heavy (retry: 5) |
SQS queue DlqFifo (FIFO) | Dead-letter target for Ordered; SQS requires FIFO sources to use FIFO DLQs |
Lambda delivery-emails | createSqsLambdaHandler invoking runlane.executeDelivery() for Emails |
Lambda delivery-ordered | createSqsLambdaHandler({ fifo: true }) for Ordered |
Lambda Tick (EventBridge cron, 1m) | runlane.tick() for schedules, retries, lease recovery, outbox flush |
Lambda Trigger (Function URL) | HTTP runlane.trigger() for ad-hoc smoke tests |
Fargate Service Worker | createSqsDeliveryConsumer for Heavy, ECS Exec enabled |
| VPC without NAT or bastion | Hosts the Fargate cluster; Lambdas run outside the VPC and all compute reaches the public RDS endpoint |
sst.x.DevCommand("AppEnv") | Exposes deploy outputs (DATABASE_URL, queue URLs, Trigger URL) for sst shell --target AppEnv -- <cmd> so Prisma migrate, seed, and smoke scripts find them through process.env |
Three acquisition paths run side by side:
| Queue | Consumer | Why |
|---|---|---|
Emails | SQS-Lambda | AWS invokes Lambda with provider records, and the handler resolves runs through executeDelivery. |
Heavy | Fargate long-running consumer | The task calls ReceiveMessage and forwards each delivery to executeDelivery. |
Ordered | FIFO SQS-Lambda | fifo: true stops a batch on first failure; messageGroup: 'queue' uses one provider group for cross-run queue ordering. |
All three write to the same Postgres durable store.
tick() runs in its own Lambda on a one-minute EventBridge schedule. It is not automatic with the Lambda SQS path; Lambda handles the provider records AWS delivered to that invocation, not global maintenance.
Schedules, retries, releases, lease recovery, and outbox publishing all need tick() running somewhere. Without it, schedules never fire, retried runs never wake, and crashed leases never recover.
Why both Lambda and Fargate
The example demonstrates that Runlane treats them as interchangeable consumers of the same durable lane:
- Lambda suits fast, stateless tasks within the 15-minute ceiling. Pay-per-invocation. No process management.
- Fargate suits long-running tasks, tasks that need warm connections to slow downstream services, or workloads that exceed Lambda's runtime/memory ceilings. Always-on cost; per-second billing.
Each Runlane logical queue is consumed by exactly one acquisition path. The example routes emails.welcome and verify.heartbeat to Emails (Lambda), media.transcode to Heavy (Fargate), and account.apply-event to Ordered (FIFO Lambda). Workloads that span both don't share a queue — split them by the resource constraint they actually have.
Local Development
The same example runs locally without AWS. Local Postgres still owns app data through Prisma, but Runlane state uses the in-memory local lane:
pnpm install
cd examples/aws
pnpm db:generate
pnpm db:emit-runlane-sql
docker run -d --name runlane-pg -p 5432:5432 \
-e POSTGRES_PASSWORD=secret -e POSTGRES_DB=runlane-test postgres:17
cp .env.example .env.local
pnpm db:migrate
pnpm seed:local
# Terminal 1: local worker plus maintenance loop.
pnpm dev
# Terminal 2: trigger and inspect work through the dev bridge.
pnpm exec runlane trigger emails.welcome '{"userId":"usr_demo_alice"}'
pnpm exec runlane runs list
pnpm exec runlane runs get <runId> --eventsSet APP_ENV=local in .env.local. createAppRuntime() then composes createLocalLane(), and runlane dev owns the in-memory runtime, starts the polling worker and maintenance loop, and opens the CLI dev bridge. Stateful CLI commands proxy into that running process, so triggers and operator reads see the same in-memory lane.
APP_ENV=dev is the AWS-shaped path used by sst dev, sst shell, and deployed resources. It requires RUNLANE_QUEUE_EMAILS_URL, RUNLANE_QUEUE_HEAVY_URL, and RUNLANE_QUEUE_ORDERED_URL, then composes the Postgres+SQS lane.
pnpm dev intentionally builds @runlane/cli before starting runlane dev. In this monorepo, the workspace runlane binary points at packages/cli/dist, so the preflight build prevents local examples from accidentally using stale CLI/core output after source edits.
Runtime Factory
Every role imports the same runtime factory. That keeps lane composition, queue policy, provider bindings, environment name, and task registration in one place:
import { createRunlane, queue, type Lane } from '@runlane/core'
import { createLocalLane } from '@runlane/lane-local'
import { postgresSqsLane } from '@runlane/lane-postgres-sqs'
import { SqsFifoDeduplication, SqsFifoMessageGroup, sqsQueue } from '@runlane/transport-sqs'
import { env } from '../env.js'
import { buildApplyAccountEventTask } from '../tasks/apply-account-event.js'
import { buildHeartbeatTask } from '../tasks/heartbeat.js'
import { buildTranscodeMediaTask } from '../tasks/transcode-media.js'
import { buildWelcomeEmailTask } from '../tasks/welcome-email.js'
import { getSqsClient } from './sqs.js'
export const emailQueue = queue({
default: true,
name: 'emails',
})
export const heavyQueue = queue({
dispatchTimeout: '5m',
concurrencyLimit: 5,
name: 'heavy',
})
export const orderedQueue = queue({
dispatchTimeout: '2m',
concurrencyLimit: 1,
name: 'ordered',
})
export function createAppRuntime() {
let lane: Lane
if (env.APP_ENV === 'local') {
lane = createLocalLane()
} else {
if (
env.RUNLANE_QUEUE_EMAILS_URL === undefined ||
env.RUNLANE_QUEUE_HEAVY_URL === undefined ||
env.RUNLANE_QUEUE_ORDERED_URL === undefined
) {
throw new Error(
'APP_ENV=dev requires RUNLANE_QUEUE_EMAILS_URL, RUNLANE_QUEUE_HEAVY_URL, and RUNLANE_QUEUE_ORDERED_URL. Set APP_ENV=local in .env.local for in-memory dev without SQS.',
)
}
lane = postgresSqsLane({
postgres: {
connectionString: env.DATABASE_URL,
schema: 'public',
},
sqs: {
client: getSqsClient(env.AWS_REGION),
queues: [
sqsQueue(emailQueue, { queueUrl: env.RUNLANE_QUEUE_EMAILS_URL }),
sqsQueue(heavyQueue, { queueUrl: env.RUNLANE_QUEUE_HEAVY_URL }),
sqsQueue(orderedQueue, {
queueUrl: env.RUNLANE_QUEUE_ORDERED_URL,
fifo: {
deduplication: SqsFifoDeduplication.ContentBased,
messageGroup: SqsFifoMessageGroup.Queue,
},
}),
],
},
})
}
return createRunlane({
environment: { name: env.RUNLANE_ENVIRONMENT },
lane,
queues: [emailQueue, heavyQueue, orderedQueue],
tasks: {
applyAccountEvent: buildApplyAccountEventTask(env),
heartbeat: buildHeartbeatTask(),
transcodeMedia: buildTranscodeMediaTask(env),
welcomeEmail: buildWelcomeEmailTask(env),
},
})
}heavy and ordered are bounded queues because they set concurrencyLimit. That is durable queue capacity enforced in Postgres, not local worker concurrency. heavy allows five active dispatch slots; ordered allows one. For bounded queues, a trigger creates the run first, then tick() reserves capacity and writes run.delivery_requested when a slot is available.
The CLI config stays a thin adapter over that factory:
import { type RunlaneCliConfig } from '@runlane/cli'
import { createAppRuntime } from './src/runtime/runlane.js'
export default {
runtime: () => createAppRuntime(),
} satisfies RunlaneCliConfigRole Entrypoints
The standard SQS Lambda handler starts the shared runtime once per execution environment and delegates every provider record to runlane.executeDelivery():
import { createSqsLambdaHandler } from '@runlane/transport-sqs'
import { formatLogCause } from '../runtime/logging.js'
import { createAppRuntime } from '../runtime/runlane.js'
const runlane = createAppRuntime()
const startupPromise = runlane.start()
export const handler = createSqsLambdaHandler({
deliveryOptions: {
leaseDuration: '2m',
},
async executeDelivery(message, options) {
await startupPromise
return runlane.executeDelivery(message, options)
},
onDeliveryFailure(failure) {
process.stderr.write(
`[delivery-emails] phase=${failure.phase} runId=${failure.deliveryMessage?.runId ?? 'unknown'} cause=${formatLogCause(failure.cause)}\n`,
)
},
})The FIFO SQS Lambda handler adds fifo: true, so the helper stops the batch after the first failure and reports that record plus later unprocessed records as batch failures:
import { createSqsLambdaHandler } from '@runlane/transport-sqs'
import { formatLogCause } from '../runtime/logging.js'
import { createAppRuntime } from '../runtime/runlane.js'
const runlane = createAppRuntime()
const startupPromise = runlane.start()
export const handler = createSqsLambdaHandler({
fifo: true,
deliveryOptions: {
leaseDuration: '2m',
},
async executeDelivery(message, options) {
await startupPromise
return runlane.executeDelivery(message, options)
},
onDeliveryFailure(failure) {
process.stderr.write(
`[delivery-ordered] phase=${failure.phase} runId=${failure.deliveryMessage?.runId ?? 'unknown'} cause=${formatLogCause(failure.cause)}\n`,
)
},
})The Fargate worker uses the long-running SQS consumer helper for Heavy. It still uses SQS as the acquisition path; it does not run a storage-polling worker:
import { createSqsDeliveryConsumer } from '@runlane/transport-sqs'
import { env } from '../env.js'
import { formatLogCause } from '../runtime/logging.js'
import { createAppRuntime } from '../runtime/runlane.js'
import { getSqsClient } from '../runtime/sqs.js'
async function main(): Promise<void> {
const heavyQueueUrl = env.RUNLANE_QUEUE_HEAVY_URL
if (heavyQueueUrl === undefined) {
throw new Error('Worker requires RUNLANE_QUEUE_HEAVY_URL. This entrypoint runs the deployed Fargate consumer.')
}
const runlane = createAppRuntime()
await runlane.start()
const consumer = createSqsDeliveryConsumer({
client: getSqsClient(env.AWS_REGION),
queueUrl: heavyQueueUrl,
deliveryOptions: {
leaseDuration: '5m',
},
executeDelivery: (message, options) => runlane.executeDelivery(message, options),
maxNumberOfMessages: 5,
waitTimeSeconds: 20,
visibilityTimeoutSeconds: 360,
onDeliveryFailure(failure) {
process.stderr.write(
`[worker] phase=${failure.phase} runId=${failure.deliveryMessage?.runId ?? 'unknown'} cause=${formatLogCause(failure.cause)}\n`,
)
},
})
process.once('SIGTERM', () => {
process.stdout.write('[worker] SIGTERM received, draining...\n')
void consumer.stop()
})
process.once('SIGINT', () => {
process.stdout.write('[worker] SIGINT received, draining...\n')
void consumer.stop()
})
await consumer.start()
process.stdout.write(`[worker] listening on ${heavyQueueUrl}\n`)
await consumer.closed
await runlane.close()
process.stdout.write('[worker] shutdown complete\n')
}Maintenance is separate from SQS delivery. The tick Lambda runs the global maintenance pass on an EventBridge schedule:
import { type ScheduledHandler } from 'aws-lambda'
import { createAppRuntime } from '../runtime/runlane.js'
const runlane = createAppRuntime()
export const handler: ScheduledHandler = async () => {
await runlane.start()
await runlane.tick()
}Tasks
emails.welcome (emailQueue, Lambda)
const welcomeEmail = task({
id: 'emails.welcome',
queue: emailQueue,
schema: z.object({ userId: z.string().min(1) }),
idempotencyKey: (payload) => `emails.welcome.${payload.userId}`,
retry: { maxAttempts: 3, backoff: { type: RetryBackoffType.Exponential, delay: '5s', maxDelay: '1m' } },
async run(payload, context) {
const user = await prisma.user.findUnique({ where: { id: payload.userId } })
if (!user) {
throw new Error(`User ${payload.userId} not found`)
}
if (user.welcomeEmailSentAt) return
await sendEmailViaProvider({ to: user.email, name: user.name, signal: context.signal })
await prisma.user.update({
where: { id: user.id },
data: { welcomeEmailSentAt: new Date() },
})
},
})Idempotency key collapses duplicate triggers for the same user to one welcome email. Repeated triggers for the same retained key return the original active or retained terminal run.
media.transcode (heavyQueue, Fargate)
const transcodeMedia = task({
id: 'media.transcode',
queue: heavyQueue,
schema: z.object({ mediaId: z.string().min(1) }),
singletonKey: (payload) => `media.transcode.${payload.mediaId}`,
retry: { maxAttempts: 5, backoff: { type: RetryBackoffType.Exponential, delay: '15s', maxDelay: '5m' } },
async run(payload, context) {
const media = await prisma.media.findUnique({ where: { id: payload.mediaId } })
if (!media) {
throw new Error(`Media ${payload.mediaId} not found`)
}
if (media.status === 'ready') return
if (media.status !== 'transcoding') {
await prisma.media.update({ where: { id: media.id }, data: { status: 'transcoding' } })
}
const status = await pollFakeBatchJob({
mediaId: media.id,
attempt: context.attempt,
signal: context.signal,
})
if (status === 'pending') {
return context.release('30s', { reason: 'transcode_in_progress' })
}
await prisma.media.update({
where: { id: media.id },
data: { status: 'ready', transcodedAt: new Date() },
})
},
})Singleton key prevents two concurrent transcode runs for the same media. context.release('30s', ...) is the canonical "external batch job not done yet" pattern from the SQS transport docs — the run becomes released, tick() re-publishes a wakeup at the future runAt, and the Fargate consumer picks it up again.
account.apply-event (orderedQueue, FIFO Lambda)
const applyAccountEvent = task({
id: 'account.apply-event',
queue: orderedQueue,
schema: z.object({
userId: z.string().min(1),
eventId: z.string().min(1),
kind: z.enum(['plan_upgraded', 'plan_downgraded', 'address_changed']),
}),
idempotencyKey: (payload) => `account.apply-event.${payload.userId}.${payload.eventId}`,
retry: { maxAttempts: 3, backoff: { type: RetryBackoffType.Exponential, delay: '5s', maxDelay: '1m' } },
async run(payload) {
const user = await prisma.user.findUnique({ where: { id: payload.userId } })
if (!user) {
throw new Error(`User ${payload.userId} not found`)
}
process.stdout.write(`[account.apply-event] user=${user.id} event=${payload.eventId} kind=${payload.kind}\n`)
},
})The queue binding uses SQS FIFO with messageGroup: SqsFifoMessageGroup.Queue, so all Ordered wakeups share one provider group. The task still owns duplicate event collapse through an idempotency key; FIFO ordering does not replace application-level idempotency.
Trigger Lambda
The HTTP trigger Lambda is only a smoke-test gateway. It still uses the same task objects registered into the runtime:
const runlane = createAppRuntime()
await runlane.start()
const triggerResult = await dispatchTrigger(request.taskId, request.payload, {
traceCarrier: { 'aws.lambda.request_id': event.requestContext.requestId },
})
if (!triggerResult) return reply(404, { code: 'unknown_task', message: 'Unknown task id' })
return reply(202, { runId: triggerResult.run.id, status: triggerResult.run.status })
async function dispatchTrigger(taskId: string, payload: unknown, options: TriggerRunOptions) {
switch (taskId) {
case runlane.tasks.welcomeEmail.id:
return runlane.trigger(runlane.tasks.welcomeEmail, welcomeEmailPayloadSchema.parse(payload), options)
case runlane.tasks.transcodeMedia.id:
return runlane.trigger(runlane.tasks.transcodeMedia, transcodeMediaPayloadSchema.parse(payload), options)
case runlane.tasks.applyAccountEvent.id:
return runlane.trigger(runlane.tasks.applyAccountEvent, applyAccountEventPayloadSchema.parse(payload), options)
default:
return null
}
}Do not rebuild task definitions inside request handlers. A runtime with an authoritative tasks catalog rejects different task objects with the same id because that is configuration drift. Also keep request ids in trace metadata; do not pass them as idempotencyKey, or they override task-owned idempotency and singleton keys.
Migration approach
The example takes option 1 from the Postgres storage migration tooling guide: Prisma runs both Runlane and app migrations.
pnpm db:emit-runlane-sql # snapshots @runlane/postgres-storage SQL
pnpm db:migrate # prisma migrate deploy applies all in orderschema.prisma describes only app tables (User, Media). getPostgresStorageMigrationSql({ schema: 'public' }) owns the Runlane table definition; the snapshot script writes it into prisma/migrations/00000000000000_runlane_init/migration.sql. The all-zero timestamp prefix sorts first lexicographically so Runlane tables exist before any app migration that depends on the schema.
Application code never queries Runlane tables through Prisma. Use runlane.runs.list, runlane.runs.get, and the rest of the operator API.
Deploy and smoke test
Run from the repo root first, then the example directory:
pnpm install
cd examples/aws
pnpm db:generate
pnpm db:emit-runlane-sql
pnpm aws:deploy
pnpm seed
pnpm smokepnpm aws:deploy runs prisma generate, sst deploy, then pnpm db:migrate:remote. The remote migrate runs under sst shell --target AppEnv, so DATABASE_URL comes from the deployed stack outputs instead of a copied password. pnpm smoke exercises Lambda delivery, Fargate delivery, FIFO ordering, release plus tick() recovery, cancellation, schedule materialization, and operator reads.
Day-to-day remote commands run through SST's AppEnv target so they receive the deployed DATABASE_URL, queue URLs, and trigger URL:
pnpm trigger emails.welcome '{"userId":"usr_demo_alice"}'
pnpm trigger media.transcode '{"mediaId":"med_demo_clip_1"}'
pnpm trigger account.apply-event '{"userId":"usr_demo_alice","eventId":"evt_1","kind":"plan_upgraded"}'
pnpm logs:trigger
pnpm logs:tick
pnpm logs:emails
pnpm logs:ordered
pnpm logs:worker
pnpm sst shell --target AppEnv -- pnpm exec runlane runs list --limit 10
pnpm sst shell --target AppEnv -- pnpm exec runlane runs get <runId> --eventsSST configuration shape
// sst.config.ts (excerpt)
const isProd = $app.stage === 'production'
const vpc = new sst.aws.Vpc('Vpc', {})
const dbPassword = new random.RandomPassword('DbPassword', { length: 32, special: false })
const dbSecurityGroup = new aws.ec2.SecurityGroup('DbSg', {
vpcId: vpc.id,
ingress: [{ protocol: 'tcp', fromPort: 5432, toPort: 5432, cidrBlocks: ['0.0.0.0/0'] }],
egress: [{ protocol: '-1', fromPort: 0, toPort: 0, cidrBlocks: ['0.0.0.0/0'] }],
})
const dbSubnetGroup = new aws.rds.SubnetGroup('DbSubnetGroup', {
name: `runlane-example-db-${$app.stage}`,
subnetIds: vpc.publicSubnets,
})
const db = new aws.rds.Instance('Db', {
identifier: `runlane-example-${$app.stage}`,
engine: 'postgres',
engineVersion: '17',
instanceClass: 'db.t4g.micro',
allocatedStorage: 20,
username: 'runlane',
password: dbPassword.result,
dbName: 'runlane',
vpcSecurityGroupIds: [dbSecurityGroup.id],
dbSubnetGroupName: dbSubnetGroup.name,
publiclyAccessible: true,
skipFinalSnapshot: true,
deletionProtection: false,
applyImmediately: true,
})
const databaseUrl = $interpolate`postgresql://${db.username}:${dbPassword.result}@${db.address}:${db.port}/${db.dbName}?sslmode=no-verify`
const dlq = new sst.aws.Queue('Dlq')
const dlqFifo = new sst.aws.Queue('DlqFifo', {
fifo: true,
})
const emailsQueue = new sst.aws.Queue('Emails', {
visibilityTimeout: '6 minutes',
dlq: { queue: dlq.arn, retry: 5 },
})
const heavyQueue = new sst.aws.Queue('Heavy', {
visibilityTimeout: '6 minutes',
dlq: { queue: dlq.arn, retry: 5 },
})
const orderedQueue = new sst.aws.Queue('Ordered', {
visibilityTimeout: '6 minutes',
fifo: { contentBasedDeduplication: true },
dlq: { queue: dlqFifo.arn, retry: 5 },
})
const sharedEnvironment = {
DATABASE_URL: databaseUrl,
RUNLANE_QUEUE_EMAILS_URL: emailsQueue.url,
RUNLANE_QUEUE_HEAVY_URL: heavyQueue.url,
RUNLANE_QUEUE_ORDERED_URL: orderedQueue.url,
RUNLANE_ENVIRONMENT: $app.stage,
}
const sharedLink = [emailsQueue, heavyQueue, orderedQueue]
emailsQueue.subscribe(
{
handler: 'src/lambda/delivery-emails.handler',
link: sharedLink,
environment: sharedEnvironment,
timeout: '1 minute',
memory: '512 MB',
},
{ batch: { partialResponses: true, size: 10 } },
)
orderedQueue.subscribe(
{
handler: 'src/lambda/delivery-ordered.handler',
link: sharedLink,
environment: sharedEnvironment,
timeout: '1 minute',
memory: '512 MB',
},
{ batch: { partialResponses: true, size: 10 } },
)
new sst.aws.CronV2('Tick', {
schedule: 'rate(1 minute)',
job: {
handler: 'src/lambda/tick.handler',
link: sharedLink,
environment: sharedEnvironment,
timeout: '2 minutes',
memory: '512 MB',
},
})
const trigger = new sst.aws.Function('Trigger', {
handler: 'src/lambda/trigger.handler',
url: true,
link: sharedLink,
environment: sharedEnvironment,
timeout: '15 seconds',
memory: '512 MB',
...(isProd ? { concurrency: { provisioned: 1 } } : {}),
})
new sst.x.DevCommand('AppEnv', {
environment: {
...sharedEnvironment,
RUNLANE_TRIGGER_URL: trigger.url,
},
dev: {
autostart: false,
command: 'true',
},
})
const cluster = new sst.aws.Cluster('Cluster', { vpc })
new sst.aws.Service('Worker', {
cluster,
link: sharedLink,
environment: sharedEnvironment,
image: { context: '../..', dockerfile: 'examples/aws/Dockerfile' },
cpu: '0.25 vCPU',
memory: '0.5 GB',
scaling: { min: 1, max: 1 },
transform: {
service: { enableExecuteCommand: true },
},
})Each compute resource is linked to the SQS queues it may publish to or consume from so SST grants IAM.
The database URL and queue URLs are passed through environment so application code reads them from process.env. AppEnv exposes the same values to post-deploy scripts through sst shell --target AppEnv.
The database URL includes sslmode=no-verify because the demo encrypts the public RDS connection without bundling the Amazon RDS CA into every runtime. Production should bundle the CA and use full certificate verification.
The Fargate image uses the monorepo root as its build context because the Dockerfile copies workspace packages as well as examples/aws.
Operational notes
- Public RDS. The database accepts TCP 5432 from anywhere, protected by the generated password printed in deploy outputs. Production should use private subnets, RDS Proxy, and a private access path.
- No NAT. Lambdas run outside the VPC. The Fargate service runs in the VPC public subnets and reaches SQS/RDS through public endpoints, so the example does not pay for a NAT gateway or NAT instance.
- DLQ shape. Standard source queues can share a standard DLQ. The FIFO
Orderedqueue uses a separate FIFO DLQ because SQS redrive policies require the dead-letter queue type to match the source queue type. - Cold starts. The example sets provisioned concurrency on the Trigger Lambda only for the
productionstage. Non-production stages accept first-invocation latency. - Tear down.
removal: 'remove'for non-production stages meanssst removedeletes RDS too. Production stages retain resources by default.
Choosing between this example and your own deploy
This example deliberately bundles both the SQS-Lambda demo and the long-running consumer demo to show the trade-off in one place. A real deployment usually picks one acquisition path per logical queue and either:
- Lambda only — if every task fits the runtime and resource ceiling, no Fargate
- Fargate only — if you don't want a Lambda dependency, no SQS-Lambda
- Both — if some tasks need long-running compute and others benefit from Lambda's burst scaling
tick() always needs a home. The reference patterns are an EventBridge cron Lambda (used here) or a setInterval inside a long-running container.