IoT Event Mode: SQS-backed Lambdas with Runtime Mode Flip
1. Overview
Two RainMaker lambdas process high-volume MQTT-derived events:
presence_event_handler— consumes IoT presence events ($aws/events/presence/disconnected/#) and updates each node’s online status in the device shadow.publish_input_event_handler— consumes device-to-cloud requests (rainmaker/nodes/+/to_cloud) such asgetGroupInfo,setNodeConfig,getSchedDetails, etc., and publishes responses back onrainmaker/nodes/<thing>/from_cloud.
Each lambda can be fed by the IoT topic rule in one of two modes, selected at runtime:
direct — the rule’s action is a
Lambdaaction carrying the function ARN, and AWS IoT callslambda:InvokeFunctiononce per event. Lowest latency, one invocation per event.sqs — the rule writes the event to a Standard SQS queue, and the lambda is invoked with a batch of up to 10 messages through an SQS event-source mapping. Batching absorbs spikes and amortises invocation overhead.
Direct invocation is the cheaper mode until the fleet is large enough for bursts to matter: 1M+ devices reconnecting after a network blip, or many devices booting together, drive one invocation per event straight into the per-account Lambda concurrency limit, and the resulting throttles turn into IoT rule retries and back-pressure on the broker. The SQS path trades a little latency for a queue that absorbs the burst.
Both paths are provisioned on every deploy, and a superAdmin REST API
(/v1/admin/iot-event-mode) flips each rule’s action between them at runtime via
iot:ReplaceTopicRule. The flip takes effect in seconds and needs no redeploy.
2. Why the mode is a runtime setting, not a build flag
Whether a deployment needs SQS batching is a question about its current load, so
it is answered at runtime on a single build artifact. Selecting the path at build
time — a Go build tag mirrored by a CDK environment variable — would make every
mode change a rebuild plus a cdk deploy, costing minutes of lag and a
CloudFormation update that rewires the IoT rule action, and would put two
divergent artifacts through CI.
flowchart LR
Broker[IoT Broker] -->|presence/disconnect| OfflineRule[node_disconnected_rule]
Broker -->|to_cloud| ToCloudRule[node_to_cloud_rule]
OfflineRule -->|Lambda action or SQS action| PresLambda[presence_event_handler]
ToCloudRule -->|Lambda action or SQS action| PubLambda[publish_input_event_handler]
PresLambda --> Shadow[(IoT Shadow)]
PubLambda --> FromCloud[from_cloud topic]
2.1 What a mode change does not affect
A single handler binary serves both modes: it sniffs the incoming payload and dispatches either one event or a batch. The two modes are therefore functionally equivalent from the caller’s point of view, which is what makes flipping safe mid-traffic and what bounds the blast radius of a partial flip.
3. Design
3.1 One handler, both payload shapes
Each handler is a single binary with one entry point. It inspects the incoming payload and dispatches accordingly, so the same build serves either mode and a flip needs no redeploy.
The discriminator is the top-level Records key: an SQS invocation always carries
it, a direct invocation from an IoT rule action never does. The probe
distinguishes “key absent” from “key present but empty”, because an empty SQS
batch must still take the SQS path — that path has to answer with a batch response
listing per-message failures, while the direct path returns nothing (IoT rule
actions discard Lambda responses).
The business logic behind both paths is identical; only the envelope differs.
3.2 Both modes provisioned at deploy time
Each handler’s infrastructure always includes the full SQS path, regardless of which mode is currently active:
flowchart LR
subgraph Always-provisioned per handler
Q[SQS Queue]
DLQ[SQS DLQ]
ESM[SqsEventSource Mapping]
LambdaPerm[lambda:InvokeFunction<br/>for iot.amazonaws.com]
RuleRole[IoT-Rule IAM Role<br/>w/ sqs:SendMessage]
ErrRole[IoT-Rule Error Role<br/>w/ logs:PutLogEvents]
end
Q --> ESM --> Lambda[event-handler lambda]
LambdaPerm --> Lambda
Concretely:
Both queues (
node-conn-queue,node-to-cloud-queue) and their DLQs always exist.Each lambda has
iot.amazonaws.com lambda:InvokeFunctionso the direct action can fire.Each lambda has
sqs:ReceiveMessageon its queue and a live event-source mapping (batch_size=10,max_batching_window=1s,report_batch_item_failures=true) so the SQS path can fire.Each rule has both an IAM role with
sqs:SendMessage(for SQS mode) and theiot.amazonaws.cominvoke permission on the lambda (for direct mode).
The synthesized rule action is always the Lambda-direct action, on every deploy. The deployment has no toggle for this. Operators who want SQS mode call the runtime API after deploy. This is intentional — see §4.4 CloudFormation drift across deploys for the reasoning.
3.3 Runtime mode flip via superAdmin API
A dedicated lambda exposes:
GET /v1/admin/iot-event-mode
PUT /v1/admin/iot-event-mode body: {"mode": "direct"|"sqs"}
Both endpoints are super-admin only.
GET — calls iot:GetTopicRule for both node_disconnected_rule and
node_to_cloud_rule and reports "sqs" if the first action is an Sqs
action, else "direct". No DB cache: the IoT rule is the single source of
truth, and this endpoint never disagrees with aws iot get-topic-rule.
{ "presence": "direct", "publish_input": "direct" }
PUT — flips both rules together. For each rule:
iot:GetTopicRuleto fetch the current SQL, SQL version, description, disabled flag, and error_action.Build a new
TopicRulePayloadreusing all of the above and replacing onlyActionswith the requested action (Lambda or SQS).iot:ReplaceTopicRulewith the new payload.
Failure semantics: presence flip first, then publish_input. If the second flip fails, the API returns 500 with a body describing which rule failed. The operation is idempotent — the caller retries until both are in the target state. There is deliberately no two-phase commit: a partial flip leaves the deployment functional, because each rule independently routes events through a working pipeline.
sequenceDiagram
participant Admin as superAdmin
participant API as API Gateway
participant L as iot_event_mode lambda
participant IoT as AWS IoT control plane
Admin->>API: PUT /v1/admin/iot-event-mode {"mode":"sqs"}
API->>L: invoke
L->>L: IsSuperAdmin check
L->>IoT: GetTopicRule(node_disconnected_rule)
IoT-->>L: {sql, errorAction, ...}
L->>IoT: ReplaceTopicRule(node_disconnected_rule, new payload)
L->>IoT: GetTopicRule(node_to_cloud_rule)
IoT-->>L: {sql, errorAction, ...}
L->>IoT: ReplaceTopicRule(node_to_cloud_rule, new payload)
L-->>API: {"presence":"sqs","publish_input":"sqs"}
3.4 IAM for the mode-flip lambda
The lambda’s role grants:
iot:GetTopicRule,iot:ReplaceTopicRuleon the two specific rule ARNs.iam:PassRoleon four roles, conditioned oniam:PassedToService=iot.amazonaws.com:presence_event_handlerIoT-rule role (used by SQS action)presence_event_handlerIoT-rule error-action rolepublish_input_event_handlerIoT-rule rolepublish_input_event_handlerIoT-rule error-action role
The PassRole requirement comes from AWS IAM: any ReplaceTopicRule call
that puts a role-bearing action (SQS, DynamoDB, etc.) or error_action into
the rule must have PassRole on every role referenced in the new payload.
The condition narrows this credential to IoT-only — it can’t be used to
attach the role to other services.
Environment variables (set by the CDK construct) supply the wiring needed
to construct each rule’s actions: PRESENCE_LAMBDA_ARN,
NODE_CONN_QUEUE_URL, PRESENCE_IOT_RULE_ROLE_ARN, plus the
publish_input equivalents.
4. Operational behaviour
4.1 What happens during a flip
Direct → SQS: in-flight Lambda invocations complete on their own threads. New events from the broker land on the SQS queue and are picked up by the existing event-source mapping. No event loss.
SQS → Direct: messages already in the queue continue to drain through the active event-source mapping (the mapping is never deleted). New events bypass the queue and invoke the lambda directly. No event loss.
In both directions the cut-over is per-event: each broker event takes whichever action is bound to the rule at the moment the rule fires.
4.2 When to flip
Situation |
Recommended mode |
|---|---|
Steady-state, < few hundred events/sec |
|
Anticipated reconnection storm (deployment-wide MQTT reset, regional ISP outage) |
|
Lambda concurrency throttles seen in metrics |
|
Backlog cleared and traffic normalised |
flip back to |
The direct path has marginally lower per-event latency (no queue hop).
The sqs path has a small per-batch latency floor (max_batching_window,
1 second) but caps invocation rate cleanly.
4.3 What NOT to do
Don’t flip SQS → Direct under heavy backlog. The queue will backlog-drain through the existing mapping (fine), but new events invoke the lambda directly and may hit per-region concurrency limits on top of the drain. Wait for the queue to drain, or stay on SQS.
Don’t decommission the SQS queue or event-source mapping thinking you’re “permanently in direct mode.” The runtime flip relies on both sides being live. The CDK construct hard-codes both as always-on.
4.4 CloudFormation drift across deploys
The runtime API mutates the IoT topic rule out-of-band via
iot:ReplaceTopicRule, then persists the chosen mode to a DynamoDB row
(rmng-admin-configs, config_key="iot_event_mode"). On every stack
create/update, a CloudFormation custom resource invokes the
iot_event_mode lambda with a {"action":"reapply"} payload; the lambda
reads the row and re-applies the stored mode to both rules. This means
the runtime-set mode survives any redeploy — including ones that edit the
rule itself (SQL change, error_action change, lambda ARN ref change),
ones that ship the synthesized template through SAM/CFN tooling rather
than cdk deploy directly, and ones that the operator does without
remembering the live state.
sequenceDiagram
participant CFN as CloudFormation
participant Rule as IoT Topic Rule
participant CR as IotEventModeReapply<br/>(AwsCustomResource)
participant L as iot_event_mode lambda
participant DDB as rmng-admin-configs
CFN->>Rule: ReplaceTopicRule (synthesized payload, Lambda-direct action)
Note over Rule: live action: Lambda direct (CFN's choice)
CFN->>CR: invoke (Update event, after Rule + Lambda are settled)
CR->>L: lambda:invoke {"action":"reapply"}
L->>DDB: GetItem config_key=iot_event_mode → {presence:sqs, publish_input:sqs}
L->>Rule: GetTopicRule + ReplaceTopicRule with stored mode
Note over Rule: live action: SQS (back to runtime-set state)
L-->>CR: {"status":"applied", ...}
CR-->>CFN: SUCCESS
Why this works
The rule itself is always synthesized with the Lambda-direct action —
there is no deploy-time toggle; nothing in the synthesis pipeline produces
an SQS action. CloudFormation rewrites the rule via the whole-payload
ReplaceTopicRule API whenever any of its properties change. After CFN
finishes that rewrite, the reapply custom resource runs:
The
AwsCustomResourceis wired withadd_dependencyon both handler stacks (so it runs after both rules are written) and on theiot_event_modelambda (so the lambda exists). Itsphysical_resource_idis timestamped, so CloudFormation invokes it on every Create/Update — not only when its own properties change.The lambda reads the durable row from
rmng-admin-configs. If the row is missing (fresh stack, never flipped), it’s a no-op — the CFN-synthesized direct mode stays.If the row says
sqs, the lambda calls the flip (the same helper the runtime API uses) for both rules, returning them to SQS.
Scenario |
Behaviour |
|---|---|
Operator flips to SQS via API, redeploys with no rule-related code change |
rule stayed SQS through CFN’s no-op update; reapply confirms |
Operator flips to SQS, redeploys after editing the rule’s SQL |
CFN rewrites rule to direct + new SQL; reapply restores SQS, SQL change persists |
Operator flips to SQS, redeploys after the lambda’s CFN logical ID changes |
CFN rewrites rule with new ARN ref + Lambda action; reapply restores SQS |
Operator never flips, redeploys |
row missing → reapply no-op; rule stays direct |
Operator flips, then re-flips back to direct, then redeploys |
row says direct; reapply applies direct (matching CFN); idempotent |
SAM / external CFN deployments
The reapply works for any CloudFormation deployment of the synthesized
template — cdk deploy, aws cloudformation deploy, sam deploy, a
custom pipeline, or a manual console update. CloudFormation custom
resources fire on every stack update regardless of which client triggered
the update, so the mechanism is transport-agnostic.
Transient state during a deploy
Between CFN’s ReplaceTopicRule (action becomes Lambda-direct) and the
reapply lambda’s restore (action returns to SQS), the rule briefly carries
the wrong action. Both modes are functionally equivalent (the unified
handler binary processes either payload), so a small fraction of events
during this window may take the unintended path. The window is bounded by
the lambda invoke time (~1 second) plus AWS IoT’s eventual consistency on
the rule update.
Failure modes
If the reapply lambda fails (DDB read error, IoT API error), the AwsCustomResource fails its Update, which fails the CFN stack update. The failure is loud by design: a mode that silently reverted on deploy would be discovered only under load.
If the flip succeeds on the rules but fails to write the DDB row, the API returns 500 and the operator retries. The next reapply pass (next deploy) heals to whatever the row eventually says — at worst the mode reverts on next deploy.
If a flip fails partway through (presence flipped, publish_input failed, or vice versa), the row is not written; the next reapply pass leaves the live state untouched (no-op). The operator retries the API.
Drift detection
aws cloudformation detect-stack-drift will flag both rules as drifted
whenever the runtime mode is sqs (CFN’s recorded template says
Lambda-direct). This is informational, not a bug — it confirms the
mechanism is working as designed.
5. Components
Component |
Role |
|---|---|
Presence handler |
Consumes presence events; sniffs the payload shape and dispatches one event or a batch |
Publish-input handler |
Same, for device-to-cloud events |
Mode-flip API |
Super-admin |
Admin-configs table |
|
Handler infrastructure |
Provisions both paths on every deploy: queue, DLQ, event-source mapping and both sets of IAM |
OpenAPI |
|
6. Testing
6.1 Unit tests
Per handler: payload-shape detection across SQS, direct and malformed input; batch handling for a full success, a partial failure that reports exactly the one bad message, an empty batch and a multi-message batch; and the existing direct-path coverage.
For the mode API: action construction for each mode, rejection of an invalid mode and of SQS mode where the queue is not wired, mode detection from a live rule, and the flip preserving the rule’s SQL, SQL version and error action.
6.2 Integration tests
Against a deployed environment, two suites cover the feature. The mode API is
exercised as super-admin and as a non-admin (403 on both GET and PUT), with
an invalid mode rejected as 400 and no rule mutated; each successful flip is
cross-checked against the live iot:GetTopicRule and a follow-up GET, and is
verified to preserve the rule’s SQL, SQL version, description, disabled flag and
error action. A round-trip direct → sqs → direct with a real connected device
confirms the pipeline keeps working across flips. Every mutating test snapshots
the starting mode and restores it on teardown, so the suite is order-independent.
A second suite writes directly to the queues, bypassing the IoT rule, to prove
the queue, event-source mapping, Lambda IAM and the handler’s SQS dispatch path
work regardless of the rule’s current mode: a synthetic disconnected event for
a live session must flip the node’s iparams shadow to online: false, and a
getGroupInfo payload dropped on the to-cloud queue must produce the correct
from_cloud response.
6.3 Operational verification
Reading the mode back through the API and comparing it against
aws iot get-topic-rule --rule-name node_disconnected_rule (the action should be
sqs or lambda accordingly) is the quickest end-to-end check after a flip.
7. Future work: automatic flipping
Out of scope for this feature. This design supports automation cleanly because the API is a simple, idempotent toggle, but the policy of when to flip is deferred until there is operational data to drive the thresholds.
A plausible automation:
flowchart LR
CW[CloudWatch alarms<br/>on throttle / concurrency / latency p99]
SNS[SNS]
Ctrl[controller lambda]
API[/v1/admin/iot-event-mode/]
CW --> SNS --> Ctrl --> API
Risks to design around when implementing:
Direction asymmetry. Auto-flip
direct → sqsis safe under load (the queue absorbs spikes). Auto-flipsqs → directunder backlog can stack drain-load on top of new direct invocations and worsen throttling. Recommend gating auto-flip to one direction (toward SQS) and keeping the reverse a manual decision.Hysteresis — alarm thresholds need cooldowns to avoid flap.
Observability — emit a CloudWatch metric on every flip so flip rate can be alarmed independently. Excessive flipping is itself a signal that thresholds are mis-tuned.