Parenting in Code: The Messenger, Part I

After years of trying, we have a child on the way. As the due date approaches, we packed our bags, lined up a dog sitter, and kept the car full of gas–we were ready to go at the drop of a hat. But if things did happen suddenly, how to let our parents know? Typing out an email on a mobile phone while rushing to the hospital seemed like a poor idea. How to automate this?

The solution I implemented was to configure an AWS IOT button to send an email to the grandparents-to-be. Then, we could simply click it when we headed out the door to the hospital. This approach does have a drawback: AWS IOT buttons require wifi access, so we’ll have to click it before we left the house.

The solution consists of the following components (see Figure 1):

Figure 1 - Solution Architecture
Figure 1 - Solution Architecture

When one of us clicks the button, AWS IOT triggers the Lambda function. The Lambda function loads data describing the email to send and who to send it to from the DynamoDB table, and then composes the email and sends it via SES.

This solution has two huge advantages to me: first, it requires a minimum of code–just a simple Lambda function. All the undifferentiated heavy lifting is done by AWS: event queues, sending non-spammy email, etc. Second, it is entirely serverless, so there is no cost until the button is pressed.1 Specifically, no EC2 or RDS instances are needed for such a simple application, and thus no ongoing cost while they idly wait for the single button press that’s ever coming.

The DynamoDB table storing the application state looks like this:

Field Type Description
buttonId String (Key) Id (serial number) of the IOT button.
sender String Address from which the email will be sent.
recipients Array of Strings Addresses to which the email will be sent.
subject String Subject line for the email.
message String Body of the email.
pressCounter Integer Counter of how many times this button has been presssed, used to prevent spamming.

This application will ever only have one row in DynamoDB–a single button, a single email to the grandparents-to-be. Perhaps I could have simplified even further by hard-coding the values in the Lambda function itself, but storing data separate from code is a good practice. It gave me a way to meaningfully test my code without spamming the grandparents, and once the code was debugged, edit the email or recipients list without risking new bugs/typos into the code. Second, it gave me an easy way to prevent any accidental clicks from sending the email multiple times (say, if the button were pressed by both my wife and I as we were heading out the door). Finally, it gave me an excuse to use DynamoDB, something I’d wanted to do for awhile.

When the IOT button is clicked, it sends a message to the Lambda function that looks like this:

{
    "serialNumber": "GXXXXXXXXXXXXXXXXX",
    "batteryVoltage": "xxmV",
    "clickType": "SINGLE" | "DOUBLE" | "LONG"
}

For this application, I don’t need to differentiate between SINGLE, DOUBLE, and LONG button presses–any button press will do. I also don’t care about the battery life of the button; Dash buttons are good for thousands of clicks, and I only need a handful for testing, plus one for the big day.

Finally, here’s my Lambda function:

from __future__ import print_function
import boto3
import json
import logging
import decimal
from boto3.dynamodb.conditions import Key, Attr
from botocore.exceptions import ClientError

# Helper class to convert a DynamoDB item to JSON.
class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, decimal.Decimal):
            if o % 1 > 0:
                return float(o)
            else:
                return int(o)
        return super(DecimalEncoder, self).default(o)
        

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ses = boto3.client('ses')
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table('LaborMessages')

def lambda_handler(event, context):
    buttonId = event['serialNumber']

    # Update counter atomically to prevent multiple clicks
    response = table.update_item(
        Key={
            'buttonId': buttonId
        },
        UpdateExpression="set pressCounter = pressCounter + :val",
        ExpressionAttributeValues={
            ':val': decimal.Decimal(1)
        },
        ReturnValues="ALL_NEW"
    )

    message = response['Attributes']
    
    if message['pressCounter'] > 1:
        message['mailSent'] = 'No'
    else: 
        # Try to send the email.
        try:
            #Provide the contents of the email.
            response = ses.send_email(
                Source = message['sender'],
                Destination={'ToAddresses': message['recipients']},
                Message={
                    'Subject': {'Data': message['subject']},
                    'Body': {'Text': {'Data': message['message']} },
                },
            )
        # Log an error if something goes wrong.	
        except ClientError as e:
            message['mailSent'] = 'Error'
            message['errorMessage'] = e.response['Error']['Message']
        else:
            message['mailSent'] = 'Yes'
            message['messageId'] = response['MessageId']
    
    logging.info(json.dumps(message,cls=DecimalEncoder))

The logic here isn’t too complicated, so I’m going to focus on some of the details.

First, I’m using DynamoDB’s updateItem call to increment the counter and return the entire row in a single, atomic call. This eliminates the race that the more naïve select row; if counter == 0 then increment counter, update row, and send email approach has: if the button is pressed a second time before the row is updated (and that update replicated to all DynamoDB servers hosting my data), then the email will be sent a second time, since both invocations of the Lambda function will receive pressCounter=0:

First Button Press Second Button Press DynamoDB
Button pressed; Select row   pressCounter = 0
  Button pressed; Select row  
pressCounter == 0? True    
  pressCounter == 0? True  
Update pressCounter = 1; commit;   pressCounter = 1
  Update pressCounter = 1; commit; pressCounter = 1
Send email Send email  

Also, since I’m incrementing pressCounter prior to retrieving the data, I check to see whether pressCounter > 1 (the first press increments the counter from 0 to 1, then the data is returned to my Lambda function). This avoids any potential race:

First Button Press Second Button Press DynamoDB
Button pressed   pressCounter = 0
  Button pressed  
Increment counter & retrieve row   pressCounter = 1
  Increment counter & retrieve row pressCounter = 2
pressCounter > 1? False pressCounter > 1? True  
Send email No email sent  

No matter the order of operations, one invocation of the Lambda function will retrieve the row with pressCounter=1 and the other will retrieve it with pressCounter=2.

The other thing I’d like to discuss for a minute is logging. Rather than log a distinct message at every step, I limit myself to one log message per invocation, with all of the interesting details of that invocation in that single log message. For a single, low-volume application like this, there would be no confusion if a single invocation logged multiple messages; however, for large-scale applications dealing with multiple hundreds of requests per second, multiple log messages per invocation quickly turns into a confusing morass of interleaved log messages to wade through. Even with modern log management software such as Splunk, digging through all of that to form a clear picture is difficult when an API request invokes multiple microservices, and each microservice logs 10-20 lines (not counting stack traces and the like). Finally, I’m logging in JSON as a best practice for third-party log analyzers.

In Part II, I’ll discuss deploying this application.

  1. Ok, there is some infintestimally small cost for the data stored in DynamoDB, but that’s well below the free tier threshold.