Before we added the database connection to our function, our runtimes were in the single-digit milliseconds, and our init duration was around 80ms. This is ok, after all we're only using the debug
profile builds so if we built with --release
we'd probably see some improvements (if netlify didn't have a bug that cause the deploy tool to ignore our release builds). My own personal experience with these runtimes in release mode is 1-2ms and 25ms cold start.
Sep 23, 11:23:28 AM: dd5c26a1 Duration: 4.91 ms Memory Usage: 25 MB Init Duration: 78.48 ms Sep 23, 11:24:15 AM: 0f4dd886 Duration: 2.31 ms Memory Usage: 26 MB
With the connection initialization and querying both being in the handler, our cold-start initialization time hasn't changed much, but our runtime ballooned to around 80ms.
Sep 23, 11:38:28 AM: 4b01c570 Duration: 100.56 ms Memory Usage: 27 MB Init Duration: 78.53 ms
Sep 23, 11:38:39 AM: 52b2fac6 Duration: 70.75 ms Memory Usage: 28 MB
Sep 23, 11:40:03 AM: 60427dcb Duration: 79.37 ms Memory Usage: 28 MB
Sep 23, 11:44:13 AM: 9201b75b Duration: 69.31 ms Memory Usage: 28 MB
Sep 23, 11:44:17 AM: 06b94a01 Duration: 74.00 ms Memory Usage: 28 MB
Sep 23, 11:44:19 AM: a6197f0d Duration: 81.35 ms Memory Usage: 28 MB
Sep 23, 11:44:31 AM: 5ecf3a7f Duration: 79.03 ms Memory Usage: 28 MB
The work we're doing in a serverless function splits into two major pieces:
- The work done when initializing: aka the "cold start" work
- The work we do on every request: aka our
function_handler
Our initialization work doesn't have much to do compared to our handler function, as evidenced by the fact that our previous cold start time and our current cold start times are more or less the same.
Looking at our handler function, we have a couple segments of work happening.
- Retrieve an environment variable
- Set up a connection pool
- Query to the database
- Serialize our result to JSON
Without measuring we can make a couple assumptions about the performance of this work and where it should happen.
Netlify doesn't let us change environment variables while a function is running, so it seems reasonable to only get it once when the function starts up.
The connection pool handles instantiating connections to the database when we need them. On first initialization, it connects to the database once, and if the pool needs more it will spin up more connections.
By placing the initialization of the connection pool in the handler function, we're ensuring that a new connection is established on each invocation of the lambda. If we can instead move this to the cold start, then we should be able to re-use connections from the pool across warm lambda invocations.
Querying the database has to happen in the handler, because we'll be using user input to adjust the query.
Serializing our JSON relies on the SQL query request so it has to be in the handler as well.
So the only large piece of work we need in our handler is the sql query.
Moving work to the initialization phase
To move the connection pool to the initialization phase, we'll use a new data type: OnceLock
. OnceLock
comes from the standard library.
use std::sync::OnceLock;
The docs describe OnceLock
as a thread-safe version of OnceCell
. We need thread-safety because we're using async
.
A synchronization primitive which can be written to only once.
This type is a thread-safe OnceCell, and can be used in statics.
Cells are data structures that exist in the standard library under the std::cell
module path. OnceCell
is an extension to this set of data structures that can only be set once, hence the name: "once" cell.
Once we set the value in our OnceLock
, we can read the value by asking the OnceLock
to give us a shared refrence to the value we're storing in it as many times as we need to, later in our program.
We need to bring OnceLock
, MySql
, and Pool
into scope.
use sqlx::{mysql::MySqlPoolOptions, MySql, Pool};
use std::sync::OnceLock;
To construct a new OnceLock
, we can use OnceLock::new()
which returns a new OnceLock
struct.
OnceLock
takes a type argument that indicates what kind of value we're going to store in it. We're going to store an instantiated MySql connection pool, so the type of POOL
will be OnceLock<Pool<MySql>>
.
static POOL: OnceLock<Pool<MySql>> = OnceLock::new();
Which brings us to static
. The static
declaration is at the root of our file, it's not inside of any function like our let
declarations are.
static
basically means: make one of these and only one of these. All references will refer to this specific instance and there will never be a second.
We can continue in our main
function, moving our database_url
and pool
initialization work from the match expression into main
.
To actually initialize the POOL
item, we can use get_or_init
, which will call init
if POOL
hasn't been initialized yet. get_or_init
takes a closure, which we return our initialized pool
from. This moves pool
into POOL
and enables us to access it later in our handler.
#[tokio::main]
async fn main() -> Result<(), Error> {
let database_url = env::var("DATABASE_URL")?;
let pool = MySqlPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
POOL.get_or_init(|| pool);
run(service_fn(function_handler)).await
}
In our handler, we can use .get()
to get a shared reference to the connection pool. .get()
could fail, although it won't for us because we always set up the pool before handling events, so we can .unwrap()
. This will panic if the POOL
isn't set, but we always set it in the cold start phase, so if it doesn't exist something has gone very wrong.
.fetch_one(POOL.get().unwrap())
This leaves us with quite a difference in cold start vs handler times. The cold start is over 150 ms, while the warm execution time of our actual data fetch is around 10ms.
Since we're looking at hard numbers, I want to be clear that these numbers go way down when we build with
--release
mode. It is truly unfortunate that Netlify doesn't correctly detect binaries built in--release
mode, but that's ok for learning about serverless functions. If you want to run serverless functions in production, you'll want to look into the AWS Lambda workshops.
Sep 23, 12:05:21 PM: INIT_START Runtime Version: provided:al2.v22 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:6c21009b8813291ce404aa6dd6aef448ced6660fa4965c4efec3a53801823417
Sep 23, 12:05:21 PM: fbbb79ea Duration: 17.78 ms Memory Usage: 28 MB Init Duration: 157.53 ms
Sep 23, 12:05:22 PM: b577e091 Duration: 9.12 ms Memory Usage: 28 MB
Sep 23, 12:05:23 PM: d3feabfa Duration: 8.67 ms Memory Usage: 28 MB
Sep 23, 12:05:24 PM: f799278f Duration: 11.14 ms Memory Usage: 28 MB
Fixing our tests
We also need to update our test functions to connect to the database, since that no longer happens inside of our handler function. This is the same logic we had before in our main function, with unwrap
instead of ?
.
async fn handles_empty_pokemon() {
let database_url =
env::var("DATABASE_URL").unwrap();
let pool = MySqlPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.unwrap();
POOL.get_or_init(|| pool);
All tests should now pass.