We close the module with the piece that is still missing. Everything MercadoFresco has built so far starts from the same idea: there is a server switched on, waiting for work. The shop's EC2 instance is running even when it is Tuesday at 4 in the morning. So is the RDS instance. And all 730 hours of the month are paid for.

But there are tasks that do not fit that model. Generating the thumbnail of a photo when Luis uploads it happens twenty times a day and takes two seconds. Looking up the status of an order is a one-off call. Processing a delivery-route file happens once every morning. Keeping a server switched on for 730 hours to do 40 minutes of work a month is absurd.

AWS Lambda runs code only when something happens and only charges while it is running, with millisecond precision. In this lesson we finally connect the S3 event we left configured back in 02-03 and write the function that generates the thumbnails for MercadoFresco's product catalogue.

Contents

  1. What serverless computing is and how it differs from EC2
  2. The execution model: handler, event, context and response
  3. Cold starts and warm starts
  4. Concurrency: reserved, provisioned and the account limit
  5. Configuration: memory, CPU, timeout, variables and architecture
  6. The thumbnail function: the code
  7. Packaging with dependencies and layers
  8. Deploying from the console and from the CLI
  9. Permissions: the execution role and least privilege
  10. Logs in CloudWatch Logs and debugging
  11. Common event sources
  12. A second function: querying an order's status over HTTP
  13. The real limits you need to know
  14. Pricing, with MercadoFresco's concrete calculation
  15. Errors, retries and dead-letter queues
  16. Module recap: what is already in AWS and what is still missing

What serverless computing is and how it differs from EC2

"Serverless" does not mean there are no servers: it means they are not yours, you never see them and you do not pay for them when they are not working. AWS provisions the capacity, scales it and takes it away again, all of it invisible.

EC2 Containers (ECS/Fargate) AWS Lambda
Unit of deployment A whole virtual machine A container image A function
Who manages the OS You AWS (with Fargate) AWS
Start-up time 30-60 s 10-30 s Milliseconds to 1-2 s
Billing Per second switched on Per second of task Per ms of execution
Cost when idle The full price The full price Zero
Maximum duration Unlimited Unlimited 15 minutes
Scaling Auto Scaling (minutes) ECS service (seconds) Automatic and instant
State on disk Persistent (EBS) Ephemeral Ephemeral (512 MB in /tmp)
Control of the environment Total High Limited
Typical use Traditional applications, databases Microservices, steady workloads Events, short tasks, irregular traffic

The criteria for choosing, in order of practical usefulness:

  1. Does the task take less than 15 minutes? If not, Lambda is ruled out.
  2. Is the traffic irregular or event-driven? Lambda wins by a mile. If it is steady and constant 24×7, a container or an EC2 works out cheaper.
  3. Do you need control of the operating system or state on disk? Then EC2 or containers.
  4. How much team time do you want to spend on infrastructure? Lambda is the least possible.

For MercadoFresco, generating thumbnails meets all four criteria perfectly: seconds of duration, triggered by sporadic events, stateless, and with nothing to administer.

The execution model: handler, event, context and response

A Lambda function is an ordinary function in your language with a specific signature:

def lambda_handler(event, context):
    # event   -> the data about what has happened (dict)
    # context -> information about this particular execution
    return {"result": "ok"}
Element What it is
Handler The entry point. It is declared as file.function, e.g. app.lambda_handler
Event (event) A dictionary with the trigger's data. Its shape depends on the source: S3, API Gateway and SQS have very different structures
Context (context) Metadata about the execution: aws_request_id, function_name, memory_limit_in_mb, get_remaining_time_in_millis()
Response Whatever the function returns. With a synchronous invocation it reaches the caller; with an asynchronous one it is discarded

The full life cycle, which is worth understanding properly because performance and cost both depend on it:

sequenceDiagram
    participant E as Event (S3)
    participant L as Lambda service
    participant M as Execution environment
    participant F as Function code

    E->>L: Something happens (a photo is uploaded)
    L->>L: Looks for an available environment
    alt COLD start (no environment available)
        L->>M: Creates a Firecracker microVM
        M->>M: Downloads the code and the layers
        M->>F: Runs the GLOBAL code (imports, boto3 clients)
        Note over M,F: 100 ms - 2 s. IT IS BILLED (since 2024)
    end
    L->>F: Invokes lambda_handler(event, context)
    F-->>L: Returns the response
    Note over M: The environment is FROZEN, not destroyed
    E->>L: Another event arrives within a few minutes
    L->>F: Reuses the environment: WARM start
    Note over M: After ~5-15 min unused, it is destroyed

This is where Lambda's most profitable optimisation comes from, and it is a single line of code:

import boto3

# CORRECT: outside the handler. It runs ONCE per environment and is reused
# by every subsequent invocation. Creating a boto3 client costs 100-300 ms.
s3 = boto3.client("s3")

def lambda_handler(event, context):
    # It would be WRONG to create the client here: you would pay those 200 ms
    # on EVERY invocation.
    ...

And this is also where the matching trap comes from: the environment is reused, so global variables persist between invocations. That is handy for caches, but disastrous if you accumulate state by accident:

results = []   # DANGER: survives between invocations

def lambda_handler(event, context):
    results.append(event)   # grows unchecked until it exhausts the memory

Cold starts and warm starts

A cold start is the time AWS takes to prepare a new environment: creating the microVM, downloading the code and the layers, initialising the interpreter and running the global code.

Factor Effect on the cold start
Language Python and Node.js: 100-400 ms. Java and .NET: 1-4 s. Go and Rust: < 100 ms
Package size The bigger it is, the longer the download takes
Initialisation code Loading a large model or connecting to a database lengthens the start-up
VPC It used to add 8-10 s; today the penalty is tens of ms
Memory allocated More memory = more CPU = faster initialisation

When it really matters: if the Lambda answers a synchronous request from a user (an API), an extra 1.5 seconds is unacceptable. If it processes an asynchronous event (a photo's thumbnail), nobody cares.

Mitigation strategies, from cheapest to most expensive:

  1. Move work into the global code and keep the package small. Free.
  2. Choose a lightweight language. Python or Node for latency-critical functions.
  3. Raise the memory. More CPU speeds up initialisation, and often the total cost does not go up because the execution is shorter.
  4. Provisioned concurrency. AWS keeps N environments always ready. It eliminates cold starts entirely, but you pay for it even when it is not used.

Concurrency: reserved, provisioned and the account limit

Lambda does not queue requests: it creates new environments. If 100 events arrive at the same time, 100 instances of the function run at once. That is its great strength and also its risk.

Concept What it is What you pay
On-demand concurrency Environments created as the events arrive Only the execution
Account limit Ceiling per region, 1,000 by default (can be raised)
Reserved concurrency A slice of the limit set aside for one function Nothing extra, but it comes off the total available
Provisioned concurrency Pre-warmed environments always ready Yes, even when idle

Reserved concurrency has a double effect that is worth understanding:

  • It guarantees that the function will always be able to use that amount, even when others are saturating the account.
  • It caps that function at that maximum. It is a brake.

That brake is exactly what MercadoFresco needs in order to protect the database:

# The function that queries orders will NEVER open more than 20 connections to
# RDS, even if 5,000 simultaneous requests arrive. It protects the database
# from becoming the link that breaks.
aws lambda put-function-concurrency \
  --function-name mercadofresco-estado-pedido \
  --reserved-concurrent-executions 20 \
  --profile mercadofresco-dev --region eu-west-1

Without that limit, a traffic spike could open hundreds of connections to mercadofresco-pedidos and bring the database down. It is a real and frequent case: Lambda scales, RDS does not.

Configuration: memory, CPU, timeout, variables and architecture

Memory and CPU go together

This is the parameter most people get wrong. In Lambda you only allocate memory, from 128 MB to 10,240 MB, and the CPU is allocated proportionally:

Memory Approximate vCPU Comment
128 MB 0.08 The minimum. Very slow for any computation
512 MB 0.29
1,769 MB 1.00 A full core. The key reference point
3,008 MB 1.70
10,240 MB 6.00 The maximum, with real multithreading

The consequence is counter-intuitive and deserves a calculation. Take a function processing an image:

Memory Duration Cost per 1,000 invocations
512 MB 8,000 ms 512/1024 × 8 × 1,000 × 0.0000166667 = 0.0667 USD
1,024 MB 4,000 ms 1 × 4 × 1,000 × 0.0000166667 = 0.0667 USD
1,769 MB 2,200 ms 1.73 × 2.2 × 1,000 × 0.0000166667 = 0.0634 USD
3,008 MB 2,000 ms 2.94 × 2 × 1,000 × 0.0000166667 = 0.0980 USD

More memory can cost exactly the same or less, and be eight times faster. Setting 128 MB "to save money" is usually a mistake: the function takes so long that the cost does not drop and the latency goes through the roof. The AWS Lambda Power Tuning tool automates this analysis.

Timeout

From 1 second to 15 minutes (900 s), with 3 seconds as the default. The rule: set it a little above what the function actually takes, never at the maximum. A timeout of 900 s on a function that should take 2 s means a hang will be billed for 15 minutes.

Environment variables

aws lambda update-function-configuration \
  --function-name mercadofresco-generar-miniaturas \
  --environment "Variables={
      BUCKET_DESTINO=mercadofresco-catalogo-fotos,
      PREFIJO_MINIATURAS=miniaturas/,
      ANCHO_MINIATURA=200,
      NIVEL_LOG=INFO}" \
  --profile mercadofresco-dev --region eu-west-1

They are encrypted at rest automatically, but they are visible in the console to anyone with read permission. Never put passwords there: use Secrets Manager or Parameter Store (lesson 04-03), as we did with RDS in 02-04.

Architecture: x86_64 versus arm64 (Graviton)

x86_64 arm64 (Graviton2)
Price The reference ~20 % cheaper
Performance The reference Equal or better on most workloads
Compatibility Universal Binary dependencies have to be compiled for ARM

Choose arm64 unless a dependency stops you. It is a 20 % saving for changing one parameter. The only precaution is that libraries with native code —such as Pillow, which we are about to use— have to be packaged for the right architecture.

The thumbnail function: the code

Here is the goal. In lesson 02-03 we configured the notification on the mercadofresco-catalogo-fotos bucket so that every .jpg uploaded under productos/ would trigger the function mercadofresco-generar-miniaturas. Now we are going to write it.

"""
mercadofresco-generar-miniaturas

Generates a 200 px wide thumbnail every time a product photo is uploaded to
the mercadofresco-catalogo-fotos bucket under the productos/ prefix.

Trigger: s3:ObjectCreated:* event filtered by the prefix 'productos/'
         and the suffix '.jpg' (configured in lesson 02-03).
Output:  an object under the 'miniaturas/' prefix of the same bucket.

IMPORTANT: the output prefix is DIFFERENT from the input one. If we wrote the
thumbnail into 'productos/' it would generate a new event that would invoke
this function again: an infinite and very expensive loop.
"""

import io
import logging
import os
import urllib.parse

import boto3
from botocore.exceptions import ClientError
from PIL import Image

# --- GLOBAL code: it runs only once per execution environment ---
# Creating the client here saves 100-300 ms on every later invocation.
s3 = boto3.client("s3")

logger = logging.getLogger()
logger.setLevel(os.environ.get("NIVEL_LOG", "INFO"))

THUMBNAIL_PREFIX = os.environ.get("PREFIJO_MINIATURAS", "miniaturas/")
WIDTH = int(os.environ.get("ANCHO_MINIATURA", "200"))
JPEG_QUALITY = 82


def _thumbnail_key(source_key: str) -> str:
    """Turns 'productos/frutas/naranjas.jpg' into 'miniaturas/frutas/naranjas.jpg'.

    The sub-prefix structure is preserved so that the thumbnail can be located
    from the original without having to query any database.
    """
    without_prefix = source_key.split("/", 1)[1] if "/" in source_key else source_key
    return f"{THUMBNAIL_PREFIX}{without_prefix}"


def _generate(data: bytes) -> bytes:
    """Resizes keeping the aspect ratio and returns the bytes of the JPEG."""
    with Image.open(io.BytesIO(data)) as img:
        # Phone photos carry their orientation in the EXIF metadata; without
        # this some thumbnails would come out rotated by 90 degrees.
        img = img.convert("RGB")

        height = int(img.height * (WIDTH / img.width))
        # LANCZOS gives the best quality when downscaling. thumbnail() never
        # enlarges, so an already small photo is left as it is.
        img.thumbnail((WIDTH, height), Image.Resampling.LANCZOS)

        output = io.BytesIO()
        img.save(output, format="JPEG", quality=JPEG_QUALITY, optimize=True)
        output.seek(0)
        return output.read()


def lambda_handler(event, context):
    """Entry point. One S3 event can carry SEVERAL records."""
    processed, failed = 0, 0

    for record in event.get("Records", []):
        bucket = record["s3"]["bucket"]["name"]
        # Keys arrive URL-encoded: 'a+b.jpg' or '%C3%B1'.
        # Without unquote_plus, a photo with spaces or accents gives NoSuchKey.
        key = urllib.parse.unquote_plus(record["s3"]["object"]["key"])

        # Defence in depth: even though the bucket filter already prevents it,
        # we check that we are not processing a thumbnail.
        if key.startswith(THUMBNAIL_PREFIX):
            logger.warning("Ignoring %s: it is already a thumbnail", key)
            continue

        try:
            logger.info("Processing s3://%s/%s", bucket, key)
            original = s3.get_object(Bucket=bucket, Key=key)["Body"].read()

            thumbnail = _generate(original)
            destination = _thumbnail_key(key)

            s3.put_object(
                Bucket=bucket,
                Key=destination,
                Body=thumbnail,
                ContentType="image/jpeg",
                CacheControl="public, max-age=604800",   # one week of caching
                Metadata={"origen": key, "generado-por": context.function_name},
                Tagging="Proyecto=mercadofresco&Componente=catalogo&Propietario=luis",
            )

            logger.info(
                "Thumbnail created: s3://%s/%s (%d KB -> %d KB)",
                bucket, destination, len(original) // 1024, len(thumbnail) // 1024,
            )
            processed += 1

        except ClientError as e:
            code = e.response["Error"]["Code"]
            if code == "NoSuchKey":
                # The object was deleted between the event and this execution.
                # It is not a recoverable error: retrying would achieve nothing.
                logger.warning("Object %s no longer exists, skipping it", key)
                continue
            logger.error("S3 error with %s: %s", key, e)
            failed += 1
            raise          # re-raising triggers Lambda's automatic retry

        except Exception as e:
            logger.exception("Unexpected error with %s: %s", key, e)
            failed += 1
            raise

    return {"processed": processed, "failed": failed}

The details in that code are what separate a tutorial example from something that survives in production:

  • unquote_plus on the key. S3 encodes names in the event. Without this line, any photo with spaces or accented characters would give NoSuchKey. It is the number one bug in S3 Lambdas.
  • event["Records"] is a list. A single event can carry several records. Processing it with event["Records"][0] works in testing and fails in production.
  • An output prefix different from the input one, plus the explicit check. Double protection against the recursive loop.
  • raise on recoverable errors. Lambda automatically retries asynchronous invocations; swallowing the exception with a return would let the failure go unnoticed.
  • NoSuchKey is handled separately. It is a non-recoverable error: retrying it three times is time and money wasted.
  • Tagging the object with the project's scheme, consistent with what we have been doing.

Packaging with dependencies and layers

Pillow is not included in the Lambda environment: you have to supply it. There are three ways and it is worth knowing when to use each one.

Method When Limit
Inline code A trivial function with no external dependencies Edited in the console
ZIP archive The usual choice: code + dependencies 50 MB compressed, 250 MB uncompressed
Layer Dependencies shared by several functions 5 layers per function, 250 MB in total
Container image Enormous dependencies (ML, ffmpeg) 10 GB

For MercadoFresco, a layer with Pillow is the right choice: it will be shared by the thumbnail function and by the ones that come later, and it keeps the code package small (faster cold starts and editing still possible in the console).

# --- Build the Pillow layer for arm64 ---
mkdir -p capa-pillow/python

# It is ESSENTIAL to build for the target platform: Pillow carries native code,
# and a macOS or x86 wheel would fail with "invalid ELF header".
pip install Pillow \
  --target capa-pillow/python \
  --platform manylinux2014_aarch64 \
  --implementation cp \
  --python-version 3.12 \
  --only-binary=:all:

cd capa-pillow && zip -r ../capa-pillow.zip python && cd ..

# Publish the layer
LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name mercadofresco-imagen \
  --description "Pillow 10 for processing product photos (arm64)" \
  --zip-file fileb://capa-pillow.zip \
  --compatible-runtimes python3.12 \
  --compatible-architectures arm64 \
  --query 'LayerVersionArn' --output text \
  --profile mercadofresco-dev --region eu-west-1)

echo "Layer published: $LAYER_ARN"

The layer's directory structure is not negotiable: Python looks for libraries in /opt/python, and AWS unpacks the layer into /opt. That is why everything has to hang from a directory called exactly python/. Each language has its own path (nodejs/node_modules for Node.js).

# --- Package the function's code (no dependencies: they live in the layer) ---
zip funcion-miniaturas.zip lambda_function.py

Deploying from the console and from the CLI

From the console

  1. Console → LambdaIreland region → Create function.
  2. Author from scratch. Name: mercadofresco-generar-miniaturas. Runtime: Python 3.12. Architecture: arm64.
  3. Change default execution role → create a new one (we will tighten it in the next section).
  4. Paste the code into the editor or upload the ZIP.
  5. Configuration → General configuration: memory 1,024 MB, timeout 30 s.
  6. Configuration → Environment variables: the four we defined earlier.
  7. Layers → Add a layermercadofresco-imagen.
  8. Triggers → Add trigger → S3 → bucket mercadofresco-catalogo-fotos, event All object create events, prefix productos/, suffix .jpg.
  9. Tags: the project's mandatory scheme.

From the CLI

aws lambda create-function \
  --function-name mercadofresco-generar-miniaturas \
  --runtime python3.12 \
  --architectures arm64 \
  --handler lambda_function.lambda_handler \
  --role arn:aws:iam::111122223333:role/rol-lambda-miniaturas \
  --zip-file fileb://funcion-miniaturas.zip \
  --layers "$LAYER_ARN" \
  --memory-size 1024 \
  --timeout 30 \
  --environment "Variables={
      PREFIJO_MINIATURAS=miniaturas/,
      ANCHO_MINIATURA=200,
      NIVEL_LOG=INFO}" \
  --tags Proyecto=mercadofresco,Entorno=produccion,Componente=catalogo,\
Propietario=luis,CentroCoste=marketing \
  --profile mercadofresco-dev --region eu-west-1

# Allow S3 to invoke the function. Without this, the event fires
# and nothing happens, with no visible error message anywhere.
aws lambda add-permission \
  --function-name mercadofresco-generar-miniaturas \
  --statement-id permitir-s3-catalogo \
  --action lambda:InvokeFunction \
  --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::mercadofresco-catalogo-fotos \
  --source-account 111122223333 \
  --profile mercadofresco-dev --region eu-west-1

Updating the code after a change:

zip funcion-miniaturas.zip lambda_function.py

aws lambda update-function-code \
  --function-name mercadofresco-generar-miniaturas \
  --zip-file fileb://funcion-miniaturas.zip \
  --publish \
  --profile mercadofresco-dev --region eu-west-1

--publish creates an immutable numbered version. Combined with aliases (produccion, pruebas), it allows gradual deployments by sending, for example, 10 % of the traffic to the new version. It is another piece against MercadoFresco's problem 4, the risky deployments, which is tackled head-on in module 8.

Testing without uploading anything to S3:

cat > evento-prueba.json <<'JSON'
{
  "Records": [{
    "eventName": "ObjectCreated:Put",
    "s3": {
      "bucket": {"name": "mercadofresco-catalogo-fotos"},
      "object": {"key": "productos/frutas/naranjas-valencia-1kg.jpg"}
    }
  }]
}
JSON

aws lambda invoke \
  --function-name mercadofresco-generar-miniaturas \
  --payload fileb://evento-prueba.json \
  --log-type Tail \
  --query 'LogResult' --output text \
  respuesta.json \
  --profile mercadofresco-dev --region eu-west-1 | base64 -d

--log-type Tail returns the last 4 KB of the log encoded in base64: the quickest way to debug without leaving the terminal.

Cost warning. Lambda's free tier is permanent, not 12 months: 1 million requests and 400,000 GB-seconds a month, forever. This exercise will not cost you anything. Even so, delete the function when you finish if you are not going on: aws lambda delete-function --function-name mercadofresco-generar-miniaturas. And remember to remove the bucket notification and the layer too if you are not using them.

Permissions: the execution role and least privilege

A Lambda function has no credentials of its own: it assumes an IAM execution role, and that role's permissions are exactly what the function can do. No more, no less.

The trust policy, which defines who can assume the role:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "lambda.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}

And the permissions policy, which defines what it can do. Here is the principle of least privilege applied rigorously:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EscribirRegistros",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/lambda/mercadofresco-generar-miniaturas:*"
    },
    {
      "Sid": "LeerSoloLasFotosOriginales",
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/*"
    },
    {
      "Sid": "EscribirSoloEnMiniaturas",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectTagging"],
      "Resource": "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/*"
    }
  ]
}

Four deliberate decisions, and each one avoids a real problem:

  1. s3:GetObject only under productos/. If the function is compromised, it cannot read the sales reports or the database backups.
  2. s3:PutObject only under miniaturas/. Even if there were a logic bug, the function cannot overwrite the original photos. It is the last line of defence against the recursive loop: even if the bucket filter failed, the writer has no permission on the input prefix.
  3. No s3:DeleteObject. The function does not need to delete anything, so it cannot.
  4. Logging scoped to its own log group, not to *.

Compared with the common practice of attaching AmazonS3FullAccess "so that it works", the difference between a minor incident and a full data leak lies in these three statements. The complete logic of IAM, roles, policies and how they are evaluated is lesson 04-01.

Logs in CloudWatch Logs and debugging

Every function automatically writes to a log group called /aws/lambda/<function-name>. Everything you print with print() or logger.info() ends up there.

# Follow the logs live while you test
aws logs tail /aws/lambda/mercadofresco-generar-miniaturas --follow \
  --profile mercadofresco-dev --region eu-west-1

# Search only the errors from the last hour
aws logs tail /aws/lambda/mercadofresco-generar-miniaturas --since 1h \
  --filter-pattern "ERROR" \
  --profile mercadofresco-dev --region eu-west-1

At the end of every invocation, Lambda writes a REPORT line that is pure gold for tuning the configuration:

REPORT RequestId: 8f3c1a2b-...  Duration: 1843.21 ms  Billed Duration: 1844 ms
       Memory Size: 1024 MB  Max Memory Used: 187 MB  Init Duration: 412.55 ms

How to read it:

Field What it tells you What to do with it
Duration The real execution time If it grows, something has degraded
Billed Duration What is billed (rounded to the ms) The basis of the cost calculation
Memory Size What you allocated 1,024 MB
Max Memory Used What it actually used 187 MB out of 1,024: memory to spare
Init Duration The cold-start time It only appears on cold starts

In this case the temptation is to drop the memory to 256 MB. Careful: remember that the CPU is proportional. With 256 MB the function would take around 7 seconds instead of 1.8, and the cost would be practically the same with four times the latency. The right move is to try 512 MB and measure.

Two other basic debugging capabilities:

  • Automatic CloudWatch metrics: Invocations, Errors, Duration, Throttles, ConcurrentExecutions. An alarm on Errors > 0 is the bare minimum in production.
  • Structured JSON logs: by setting the log format to JSON, the fields can be queried with CloudWatch Logs Insights without parsing text.

Monitoring in depth —dashboards, alarms, retention, Logs Insights— is lesson 05-01, and the distributed tracing of a request that crosses several functions and services is 05-02.

Common event sources

Lambda integrates with more than 200 services. These are the ones that matter when you start:

Source Invocation Event structure MercadoFresco use case Covered in
Amazon S3 Asynchronous Records[].s3 Generating thumbnails 02-03 and this lesson
API Gateway / Function URL Synchronous requestContext, body Order status endpoint This lesson
Amazon SQS Batch polling Records[].body Processing orders from the Friday peak Module 7 (07-01)
Amazon SNS Asynchronous Records[].Sns Reporting a delivery incident Module 7 (07-02)
Amazon EventBridge Asynchronous detail, source A scheduled task every morning Module 7 (07-03)
DynamoDB Streams Batch polling Records[].dynamodb Reacting to data changes Module 6 (06-02)
CloudWatch Logs Asynchronous Compressed data Alerting on error patterns Module 5 (05-01)

The three invocation modes have very different consequences for error behaviour:

Mode Who waits for a response Automatic retries Example
Synchronous The caller waits None (the client handles them) API Gateway
Asynchronous Nobody waits 2 retries S3, SNS
Polling Lambda reads from the source Until it expires or goes to the failure queue SQS, DynamoDB Streams

A second function: querying an order's status over HTTP

The first function reacted to an internal event. Now we are going to expose a function to the internet so that MercadoFresco's mobile app can look up the status of an order.

There are two ways of putting HTTP in front of a Lambda:

Function URL API Gateway
Configuration One checkbox A separate service to configure
Cost Free ~1 USD per million requests
Custom domain No Yes
Authentication None or IAM IAM, Cognito, JWT, custom authorisers
Rate limiting No Yes
Multiple routes No, a single URL Yes, full routing
When Prototypes, internal webhooks Production

We start with a Function URL for simplicity, knowing that production will ask for API Gateway.

"""
mercadofresco-estado-pedido

Returns the status of an order from its identifier.
Invocation: Function URL (HTTP GET), e.g. /?pedido=48213

Reserved concurrency = 20: the RDS database cannot cope with hundreds of
simultaneous connections, so the brake goes here.
"""

import json
import logging
import os

import boto3
import psycopg

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

# --- Global code: it runs once per environment ---
# The secret is read ONCE and reused by the following invocations.
_secrets = boto3.client("secretsmanager")
_credentials = json.loads(
    _secrets.get_secret_value(SecretId=os.environ["MF_SECRETO_BD"])["SecretString"]
)

DSN = (
    f"host={os.environ['MF_BD_HOST']} port=5432 dbname=pedidos "
    f"user={_credentials['username']} password={_credentials['password']} "
    f"sslmode=require connect_timeout=3"
)

# One connection per execution environment, reused between invocations.
# It is the reason why reserved concurrency caps the connections to RDS.
_connection = None


def _get_connection():
    global _connection
    if _connection is None or _connection.closed:
        _connection = psycopg.connect(DSN)
    return _connection


def _response(status: int, body: dict) -> dict:
    """The response format a Function URL or API Gateway expects."""
    return {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json; charset=utf-8",
            "Cache-Control": "no-store",
        },
        "body": json.dumps(body, ensure_ascii=False),
    }


def lambda_handler(event, context):
    # Query parameters arrive in queryStringParameters (it can be None).
    params = event.get("queryStringParameters") or {}
    order_id = params.get("pedido")

    if not order_id or not order_id.isdigit():
        return _response(400, {"error": "Missing 'pedido' parameter, or it is not numeric"})

    try:
        conn = _get_connection()
        with conn.cursor() as cur:
            # Parameterised query: NEVER concatenate the identifier into the SQL.
            cur.execute(
                """
                SELECT id, estado, creado_en, entrega_estimada, ciudad
                FROM pedidos
                WHERE id = %s
                """,
                (int(order_id),),
            )
            row = cur.fetchone()

        if row is None:
            return _response(404, {"error": "Order not found"})

        return _response(200, {
            "pedido": row[0],
            "estado": row[1],
            "creado_en": row[2].isoformat(),
            "entrega_estimada": row[3].isoformat() if row[3] else None,
            "ciudad": row[4],
        })

    except psycopg.OperationalError as e:
        # The connection may have died because of an RDS failover (lesson 02-04).
        # It is discarded so that the next invocation creates a new one.
        logger.error("Database connection error: %s", e)
        global _connection
        _connection = None
        return _response(503, {"error": "Service temporarily unavailable"})

    except Exception:
        logger.exception("Unexpected error while querying order %s", order_id)
        # Never return the exception detail to the client: it leaks information.
        return _response(500, {"error": "Internal error"})
# Create the Function URL. AuthType NONE leaves it public: for testing only.
# In production you put API Gateway with authentication in front.
aws lambda create-function-url-config \
  --function-name mercadofresco-estado-pedido \
  --auth-type NONE \
  --cors '{"AllowOrigins":["https://mercadofresco.example"],"AllowMethods":["GET"]}' \
  --profile mercadofresco-dev --region eu-west-1

# Cap the concurrency to protect RDS
aws lambda put-function-concurrency \
  --function-name mercadofresco-estado-pedido \
  --reserved-concurrent-executions 20 \
  --profile mercadofresco-dev --region eu-west-1

Four design decisions that deserve attention:

  • The database connection lives in the global scope and is reused. Opening a connection to PostgreSQL costs tens of milliseconds; doing it on every invocation would multiply the latency and saturate RDS.
  • Reserved concurrency of 20 as a ceiling on connections. Without it, a spike could open hundreds of connections and bring mercadofresco-pedidos down. It is the lesson from 02-04 applied: Lambda scales, RDS does not. (For demanding cases there is RDS Proxy, which pools connections; it is outside the scope of this lesson.)
  • A parameterised query with %s, never string concatenation. SQL injection avoided.
  • Internal errors are not detailed to the client: they are logged to CloudWatch and a generic message is returned.

The real limits you need to know

Limit Value Practical consequence
Maximum duration 15 minutes A longer process needs Step Functions (07-04), ECS or EC2
Memory 128 MB to 10,240 MB The CPU is tied to this value
Compressed ZIP package 50 MB (direct upload) Above that, upload from S3
Uncompressed package + layers 250 MB The limit that gets in the way most. Alternative: a container image (10 GB)
Space in /tmp 512 MB by default, up to 10,240 MB Enough for the photos; configurable with --ephemeral-storage
Synchronous payload 6 MB in and out Do not return large files: store them in S3 and return a pre-signed URL (02-03)
Asynchronous payload 256 KB The S3 event fits with room to spare
Concurrency per region 1,000 by default Can be raised with a quota request
Environment variables 4 KB in total Do not put configuration files in there
Layers per function 5 Group related dependencies together

The 15-minute limit is the one that conditions the most architectural decisions. If the nightly process that recompresses the catalogue's 40,000 photos takes 3 hours, it is not a job for a single Lambda: either it is split into 40,000 two-second invocations (which do fit perfectly), or it runs as a container task. Splitting is almost always the right answer with Lambda.

Pricing, with MercadoFresco's concrete calculation

Lambda charges for two things:

  1. Requests: 0.20 USD per million.
  2. Duration: 0.0000166667 USD per GB-second (x86_64; arm64 is 20 % less).

And the free tier is permanent, not 12 months: 1 million requests and 400,000 GB-seconds a month, forever.

Case 1: the thumbnail function. Luis uploads about 20 photos a day (600 a month), the function uses 1,024 MB and takes 1.8 seconds:

Requests:  600 → within the free million                = 0.00 USD
Duration:  600 × 1.8 s × 1 GB = 1,080 GB-second
           → within the free 400,000                    = 0.00 USD
--------------------------------------------------------------
Total                                                   = 0.00 USD/month

Zero cost. The equivalent on a t3.micro EC2 kept running to do the same would be around 7.50 USD a month. This is exactly the use case Lambda exists for.

Case 2: the order status endpoint, in the realistic scenario. MercadoFresco receives around 900,000 queries a month, the function uses 512 MB and takes 120 ms, on arm64:

Requests:  900,000 → within the free million                = 0.00 USD
Duration:  900,000 × 0.12 s × 0.5 GB = 54,000 GB-s
           → within the free 400,000                        = 0.00 USD
--------------------------------------------------------------
Total                                                       = 0.00 USD/month

Case 3: growth to three more cities (problem 3). Ten times the traffic: 9 million queries a month:

Requests:  (9,000,000 − 1,000,000) / 1,000,000 × 0.20     =  1.60 USD
Duration:  9,000,000 × 0.12 × 0.5 = 540,000 GB-second
           (540,000 − 400,000) × 0.0000166667             =  2.33 USD
arm64 discount (−20 % on the duration)                    = −0.47 USD
------------------------------------------------------------------------
Total                                                      ≈ 3.46 USD/month

Less than 4 dollars a month to serve 9 million requests, with no server to administer, patch or watch over. When the load is irregular and event-driven, the serverless model is very hard indeed to beat.

The break-even point, worth keeping in mind: above a sustained utilisation of 50-60 % of a server, a container or a reserved EC2 works out cheaper. Lambda is expensive if you use it as though it were an always-on server.

Errors, retries and dead-letter queues

What happens when the function fails depends on the invocation mode:

Mode Behaviour on error
Synchronous (Function URL, API Gateway) The error is returned to the client. No retries
Asynchronous (S3, SNS, EventBridge) Lambda retries 2 times with increasing backoff; if it keeps failing, the event is discarded
Polling (SQS, DynamoDB Streams) It retries until the message expires or goes to the dead-letter queue

"The event is discarded" is the dangerous phrase. If the thumbnail function fails three times on one particular photo, that photo is left with no thumbnail and nobody finds out. The protection is an on-failure destination or a dead-letter queue (DLQ): somewhere for the events that could not be processed to end up, so they can be reviewed and reprocessed.

# Configure destinations: failures go to an SQS queue, successes are ignored.
aws lambda put-function-event-invoke-config \
  --function-name mercadofresco-generar-miniaturas \
  --maximum-retry-attempts 2 \
  --maximum-event-age-in-seconds 3600 \
  --destination-config '{
    "OnFailure": {
      "Destination": "arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas"
    }
  }' \
  --profile mercadofresco-dev --region eu-west-1

Three principles to respect from day one:

  1. Idempotency. Delivery is "at least once": the same photo can be processed twice. Our function satisfies this because regenerating a thumbnail over itself gives the same result. If the function incremented a counter or charged a payment, it would need explicit protection.
  2. Tell recoverable errors from unrecoverable ones. A network timeout deserves a retry; a NoSuchKey does not. Retrying the unrecoverable is time and money thrown away.
  3. Watch the DLQ. A failure queue nobody looks at is the same as not having one. An alarm on ApproximateNumberOfMessagesVisible > 0 is mandatory.

Here we are only setting out the concept. SQS is covered in lesson 07-01, and the complete patterns of idempotency, retries and dead-letter queues are lesson 07-05.

Module recap: what is already in AWS and what is still missing

This is the point in the course where it is worth looking up. MercadoFresco started module 2 with a physical server in the office; it finishes with this:

flowchart TB
    subgraph AWS["Account 111122223333 - eu-west-1 (Ireland)"]
        subgraph COMPUTE["Compute"]
            ASG["Auto Scaling Group<br/>asg-mercadofresco-tienda<br/>2-4 t3.micro instances<br/>across two AZs (02-01)"]
            L1["Lambda<br/>generar-miniaturas (02-05)"]
            L2["Lambda<br/>estado-pedido (02-05)"]
        end
        subgraph DATA["Data"]
            S3["S3 mercadofresco-catalogo-fotos<br/>versioning + lifecycle (02-03)"]
            RDS["RDS PostgreSQL<br/>mercadofresco-pedidos<br/>Multi-AZ + replica (02-04)"]
            EBS["EBS gp3 + DLM snapshots (02-02)"]
        end
        ASG --> EBS
        ASG --> RDS
        S3 -->|"ObjectCreated event"| L1
        L1 --> S3
        L2 --> RDS
    end
    U["MercadoFresco customers"] --> ASG
    U --> L2

What is solved and what is not:

Problem Status Where it was solved
1. The Friday outages Partial: there is Auto Scaling, the load balancer that spreads the traffic is missing 02-01; completed in 03-03
2. Unreliable backups Solved: EBS snapshots with DLM, S3 versioning, automated RDS backups with PITR 02-02, 02-03, 02-04
3. Not being able to grow to more cities Partial: the infrastructure now scales, global distribution and a decoupled architecture are missing 02-01, 02-05; modules 3, 6 and 7
4. Risky deployments Started: versioned launch templates, Lambda versions and aliases 02-01, 02-05; solved in module 8

And what still does not exist, which is a great deal:

  • A network of our own. Everything lives in the default VPC, with the instances exposed. We still have to design public and private subnets, route tables, and move the database into a subnet with no way out to the internet. Module 3.
  • A load balancer. The four instances in the ASG do not get traffic spread across them, and there is no HTTPS or custom domain. 03-03 and 03-05.
  • Content delivery. 97 % of the S3 bill was outbound transfer: CloudFront fixes that. 03-04.
  • Identity and secrets done properly. We have used roles and Secrets Manager in passing; the full IAM structure, encryption with our own keys and protection against attacks are still missing. Module 4.
  • Observability. We have scattered metrics and the odd log. We are missing a system of dashboards, alarms, traces and an audit trail of who did what. Module 5.
  • Infrastructure as code. We have created everything by hand or with one-off commands: it is neither reproducible nor reviewable. Module 9.

Common Mistakes and Tips

  • Creating boto3 clients inside the handler. You pay 100-300 ms on every invocation. Always in the global scope.
  • Not decoding the S3 key with unquote_plus. Any photo with spaces or accented characters will fail with NoSuchKey. It is the number one bug in S3-triggered Lambdas.
  • Processing only event["Records"][0]. An event can carry several records. Always iterate.
  • Writing to the same prefix that triggers the function. Infinite loop. Filter by prefix and restrict the role's write permissions, as a double defence.
  • Allocating 128 MB "to save money". The CPU is proportional to the memory: the function is so slow that the cost does not drop and the latency soars. Measure with Max Memory Used.
  • Leaving the timeout at 900 s by default. A hang will be billed for 15 minutes.
  • Keeping state in global variables without control. The environment is reused and a global list grows until it exhausts the memory.
  • Giving AdministratorAccess to the execution role. Grant only the exact actions and resources the function needs.
  • Forgetting lambda:add-permission for the event source. The trigger does not work and no visible error appears anywhere.
  • Opening an RDS connection per invocation with no concurrency limit. A traffic spike brings the database down. Connection in the global scope and reserved concurrency.
  • Not configuring an on-failure destination or DLQ for asynchronous invocations. Failed events are discarded silently.
  • Returning large files in the response. The synchronous limit is 6 MB. Store in S3 and return a pre-signed URL.
  • Tip: start on arm64 unless a dependency prevents it. It is a 20 % saving for changing one parameter.

Exercises

Exercise 1: choosing the compute technology

For each MercadoFresco workload, decide between Lambda, EC2/container or a combination, and justify it with the criteria from the lesson:

  • A) The PHP web shop, which serves traffic continuously with a peak on Fridays.
  • B) Generating the invoice PDF when an order is marked as delivered (around 900 a day, 1.5 s each).
  • C) Recompressing the catalogue's 40,000 photos, a process that today takes 3 hours straight.
  • D) A job that every night at 03:00 exports the day's orders to S3; it takes 90 seconds.
  • E) A recommendation service that loads a 4 GB model into memory and answers in under 50 ms.

Exercise 2: calculating and optimising the cost

The thumbnail function is configured with 1,024 MB and takes 1,800 ms. Tests with AWS Lambda Power Tuning give these results:

Memory Duration
512 MB 3,400 ms
1,024 MB 1,800 ms
1,769 MB 1,150 ms
3,008 MB 1,050 ms

MercadoFresco is growing and 200,000 photos a month are now processed (already outside the free tier for duration). Calculate the duration cost of each configuration on arm64 (0.0000133334 USD per GB-second) and decide which one you would choose, taking latency into account as well.

Exercise 3: writing the least-privilege role

MercadoFresco adds a third function, mercadofresco-exportar-pedidos, which every night:

  1. Reads the day's orders from the RDS read replica.
  2. Gets the database credentials from Secrets Manager.
  3. Writes a CSV file to s3://mercadofresco-informes-analitica/informes/YYYY/MM/.
  4. Sends a notification to an SNS topic when it finishes.
  5. Writes its logs to CloudWatch Logs.

Write the execution role's permissions policy applying least privilege, and explain two permissions you deliberately do not include even though they might look necessary.

Solutions

Solution 1.

Case Choice Justification
A) PHP web shop EC2 with Auto Scaling (what we already built in 02-01) Continuous traffic, a monolithic application with state on disk, legacy code. The sustained utilisation makes Lambda more expensive and the redesign does not pay off
B) Invoice PDF Lambda 900 × 1.5 s = 22.5 minutes of compute a day. It is a short, event-driven, stateless task: the textbook case. An EC2 would be idle 98 % of the time
C) Recompressing 40,000 photos Lambda, but split up 3 hours straight is over the 15-minute limit. The answer is not to change technology: it is to split it into 40,000 invocations of ~2 s running in parallel, and the total work drops from 3 hours to a few minutes. A valid alternative: a container task if the process cannot be parallelised
D) 90-second nightly export Lambda + scheduled EventBridge Well under 15 minutes, run once a day. Keeping a server for 90 seconds a day makes no sense. EventBridge is covered in 07-03
E) Recommender with a 4 GB model Container on ECS/Fargate The model exceeds the package's 250 MB limit (a 10 GB container image could be used), but the real problem is the cold start: loading 4 GB takes seconds and the requirement is 50 ms. It needs an always-on process with the model in memory. Module 10

Solution 2.

Duration cost on arm64, for 200,000 invocations:

GB-second = invocations × (memory_GB) × (duration_s)
Cost      = (GB-second − 400,000 free) × 0.0000133334
Memory GB Duration GB-second Billable Cost
512 MB 0.50 3.4 s 340,000 0 (within the free tier) 0.00 USD
1,024 MB 1.00 1.8 s 360,000 0 (within the free tier) 0.00 USD
1,769 MB 1.73 1.15 s 397,900 0 (within the free tier) 0.00 USD
3,008 MB 2.94 1.05 s 617,400 217,400 2.90 USD

A revealing result: the first three configurations cost exactly the same, nothing, because they all fit inside the free tier of 400,000 GB-seconds. The decision, therefore, is not an economic one but a matter of latency and headroom.

Choice: 1,769 MB. Reasons:

  • It is the fastest of the three free ones: 1.15 s against 3.4 s, almost three times better.
  • 1,769 MB corresponds to a full vCPU, the point where Pillow stops being CPU-bound.
  • It sits at 397,900 GB-seconds, very tight against the limit. If the volume grows it will start to cost: at 300,000 photos it would be 596,850 GB-seconds, that is 2.62 USD a month. Still irrelevant.
  • 3,008 MB is ruled out: it only gains 100 ms over 1,769 MB (the function is no longer CPU-bound) and it multiplies the GB-second consumption, pushing it out of the free tier.

The general lesson: above one vCPU, carrying on raising the memory stops speeding things up and starts costing money. The sweet spot is usually near 1,769 MB for single-threaded work.

Solution 3.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Registros",
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:eu-west-1:111122223333:log-group:/aws/lambda/mercadofresco-exportar-pedidos:*"
    },
    {
      "Sid": "LeerSoloElSecretoDeLaBaseDeDatos",
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:rds!db-a1b2c3d4-*"
    },
    {
      "Sid": "EscribirSoloLosInformes",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::mercadofresco-informes-analitica/informes/*"
    },
    {
      "Sid": "PublicarSoloEnElTemaDeAvisos",
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "arn:aws:sns:eu-west-1:111122223333:mercadofresco-exportaciones"
    },
    {
      "Sid": "InterfazDeRedParaAccederALaVPC",
      "Effect": "Allow",
      "Action": [
        "ec2:CreateNetworkInterface",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DeleteNetworkInterface"
      ],
      "Resource": "*"
    }
  ]
}

Two permissions deliberately NOT included:

  1. s3:GetObject and s3:DeleteObject on the reports bucket. The function only writes: it does not need to read or delete anything. Leaving them out means that, if the function were compromised by a malicious dependency, it could not exfiltrate the historical sales reports or destroy them. It is the same reasoning we applied to the thumbnail function.

  2. Any rds:* permission. It feels counter-intuitive, because the function queries the database, but connecting to PostgreSQL with a username and password does not go through IAM: it is a TCP connection authenticated by the engine itself. The rds:* permissions are for administering instances (creating, deleting, modifying them), and the function must not be able to do any of that. Granting them would be giving permission to delete the database to a process that only needs to read a table. (The exception would be RDS IAM authentication, which does require rds-db:connect on a specific user resource.)

Two further design details: the secret's ARN carries a trailing wildcard because Secrets Manager adds a random six-character suffix to the name; and the ec2:*NetworkInterface permissions are mandatory and with Resource: "*" when the function connects to resources inside a VPC —it is one of the very few legitimate exceptions to least privilege, imposed by the service itself.

Conclusion

With this lesson MercadoFresco closes its first module of real services. You know what serverless computing is and, more importantly, when not to use it: you have compared Lambda with EC2 and with containers in a table of criteria and applied the rule that above 50-60 % sustained utilisation the model stops paying off. You have mastered the execution model —handler, event, context, response— and the environment's life cycle, from which the most profitable optimisation in Lambda follows: creating the boto3 clients in the global scope, outside the handler, because the environment is reused. You know what a cold start is, what lengthens it and the four ways of mitigating it, and you know that reserved concurrency is at once a guarantee and a brake, the brake that protects RDS from Lambda's ability to scale without limit.

You have understood the memory-CPU-cost relationship, the parameter most people get wrong, and you have worked it out with real numbers: allocating 128 MB "to save money" usually costs just as much and is eight times slower. You know to choose arm64 for a 20 % saving, to set a tight timeout and not to keep passwords in environment variables.

And you have written the function that closes the circle opened in lesson 02-03: mercadofresco-generar-miniaturas fires on the s3:ObjectCreated event of the mercadofresco-catalogo-fotos bucket, decodes the key properly with unquote_plus, iterates over every record in the event, writes to a prefix different from the input one so as not to create an infinite loop, tells recoverable errors from unrecoverable ones and tags what it produces. You have packaged it with Pillow in a layer built for the right platform, deployed it from the console and from the CLI with create-function and update-function-code --publish, and given it a least-privilege execution role that can only read under productos/, can only write under miniaturas/ and cannot delete anything. You have added a second function, mercadofresco-estado-pedido, exposed through a Function URL, with the RDS connection reused between invocations, parameterised queries, concurrency capped at 20 and errors that leak no information to the client. You know how to read the REPORT line in CloudWatch Logs to tune the memory, you have walked through the common event sources noting where each one is covered, you know the real limits —15 minutes, 250 MB, 512 MB in /tmp, a 6 MB synchronous payload— and you have done the pricing calculation that shows that serving 9 million requests costs less than 4 dollars a month. Finally, you know what happens when a function fails in each invocation mode and why a dead-letter queue nobody watches is the same as not having one.

Recapping the whole module: MercadoFresco now has, in eu-west-1, an Auto Scaling group for the shop spread across two Availability Zones, EBS disks with automated snapshots managed by Data Lifecycle Manager, the photo catalogue in S3 with versioning and lifecycle rules, the orders database in RDS PostgreSQL with Multi-AZ, a read replica and point-in-time recovery, and two Lambda functions that react to events and answer over HTTP. Problem 2, the unreliable backups, is solved from start to finish. The other three are under way but they are still incomplete.

And what is missing is, precisely, what holds everything else up. All of this lives in the default VPC: the instances are exposed where they should not be, the database shares network space with the shop, there is nothing spreading the traffic across Friday's four instances, there is no HTTPS or custom domain, and 97 % of the S3 bill is still outbound transfer with no cache. In module 3, "Networking and Content Delivery", starting with lesson 03-01 "Amazon VPC", we will build MercadoFresco's own network: public and private subnets across two Availability Zones, route tables, gateways and a database that stops being reachable from the internet. On top of that network we will then build the security groups, the load balancer that completes the answer to the Friday peak, CloudFront to make the photos cheaper and faster, and Route 53 so that mercadofresco.example finally points to the cloud.

© Copyright 2026. All rights reserved