Introduction
Ad-serving systems frequently need to accommodate millions, or even billions, of requests daily, constructing and delivering personalized ads within just a few milliseconds. Beyond handling the substantial daily volume of requests, these platforms must be capable of managing spiky traffic with sudden surges in user activity as well.
In this blog post, we will construct a real-time ad cache server utilizing the cutting-edge technologies of Bun, ElysiaJS, and Dragonfly. Not just for the enjoyment of exploring new tools, but also to leverage their exceptional developer experience and performance capabilities. Let's take a brief look at the technologies we will be using in this post:
- Bun, an all-in-one JavaScript/TypeScript runtime and toolkit that reached 1.0 a month ago, stands out for speed and efficiency, making it a perfect choice for high-performance applications.
- ElysiaJS is a TypeScript framework supercharged by Bun with end-to-end type safety and outstanding developer experience.
- Last but not least, Dragonfly, our high-throughput, multi-threaded in-memory data store, often acts as a robust drop-in replacement for Redis, capable of handling up to 4 million QPS and managing 1TB of workload on a single machine.
The code snippets provided throughout this blog post can be found in the dragonfly-examples repository. We strongly encourage you to clone this repository and follow along with the provided code examples as you read through this blog post. Engaging with the code firsthand is an excellent way to get your hands dirty and gain practical experience with the concepts and technologies we are covering.
Ad Serving Functionalities
In this section, we will dive into a sample architecture, showcasing the practical application of Dragonfly in the context of an ad server. Within an ad-serving platform, two fundamental and commonly utilized functionalities include:
- Ad Metadata Management
- Ad User Preference Management
We will explore each of these in detail below.
Ad Metadata Management
Effectively managing a diverse array of advertisements is a critical requirement for an ad-serving platform.
This entails maintaining comprehensive details for each advertisement, including elements such as the ad title, description, image URL, click URL, and more.
The system must be agile, swiftly incorporating new ads, updating existing entries, and retrieving ads when they are requested for display.
The Hash
data type that Dragonfly supports is a perfect candidate for this.
dragonfly$> HGETALL ad:metadata:1
1) "id"
2) "1"
3) "title"
4) "Dragonfly - a data store built for modern workloads"
5) "category"
6) "technology"
7) "clickURL"
8) "https://www.dragonflydb.io/"
9) "imageURL"
10) "https://www.dragonflydb.io/blog"
Ad User Preference Management
Ad prioritization tailored to user preferences not only enhances the overall user experience but also significantly boosts ad effectiveness, driving higher engagement and conversion rates.
By serving ads that align with user preferences, ad platforms can maximize their value for both advertisers and users.
Let's assume we categorize ads into various categories (such as sports
, technology
, fashion
, etc.), and we also track the categories each user is interested in.
Both ad categories and user preferences can be efficiently stored using the Set
data type that Dragonfly supports, with each ad category having a category-set, and each user having a preference-set.
Upon a user's visit, we can retrieve their preferences, search for corresponding ads for each category, and subsequently display ads that align with the user's preferences.
dragonfly$> SMEMBERS ad:category:technology
1) "1"
2) "2"
dragonfly$> SMEMBERS ad:user_preference:1
1) "sports"
2) "technology"
As shown above, the ad category technology
has two ads with IDs 1
and 2
.
And the user with ID 1
has two preferences: sports
and technology
.
Run Dragonfly & Ad Server Application
1. Prerequisites
- Make sure Docker is installed and running locally, which will be used to run Dragonfly.
- Make sure Bun is installed locally. We will be using Bun to run our TypeScript application.
2. Start Dragonfly
First of all, let's make sure that we have Dragonfly running locally so that it is easier to run the sample application. There are a few ways to get started with Dragonfly. For this blog post, we will be using Docker to run Dragonfly with just one command:
docker run -p 6379:6379 --ulimit memlock=-1 docker.dragonflydb.io/dragonflydb/dragonfly
Upon successful execution, you should see Dragonfly outputting logs similar to the following:
I20231024 15:43:44.752050 1 init.cc:70] dragonfly running in opt mode.
I20231024 15:43:44.752230 1 dfly_main.cc:798] Starting dragonfly df-v1.11.0-c6f8f3882a276f6016042016c94401242d9c5365
W20231024 15:43:44.752424 1 dfly_main.cc:837] SWAP is enabled. Consider disabling it when running Dragonfly.
I20231024 15:43:44.752456 1 dfly_main.cc:842] maxmemory has not been specified. Deciding myself....
I20231024 15:43:44.752461 1 dfly_main.cc:851] Found 6.58GiB available memory. Setting maxmemory to 5.26GiB
I20231024 15:43:44.758332 9 uring_proactor.cc:157] IORing with 1024 entries, allocated 102720 bytes, cq_entries is 2048
I20231024 15:43:44.782545 1 proactor_pool.cc:147] Running 5 io threads
I20231024 15:43:45.196280 1 snapshot_storage.cc:112] Load snapshot: Searching for snapshot in directory: "/data"
W20231024 15:43:45.196475 1 server_family.cc:466] Load snapshot: No snapshot found
I20231024 15:43:45.207091 11 listener_interface.cc:83] sock[13] AcceptServer - listening on port 6379
3. Clone the Example Repository
As previously mentioned, all the code examples featured in this post are accessible in the dragonfly-examples repository.
Clone the repository to your local machine using the command provided below.
Once the repository is cloned, proceed by navigating to the ad-server-cache-bun
subdirectory, where you'll find all the code snippets used in this blog post.
git clone git@github.com:dragonflydb/dragonfly-examples.git
cd dragonfly-examples/ad-server-cache-bun
4. Install Dependencies & Run the Application
Within the dragonfly-examples/ad-server-cache-bun
directory, use Bun to install the dependencies for the real-time ad server application.
As shown below, Bun will install the dependencies listed in the package.json
file, including ElysiaJS and ioredis.
bun install
# bun install v1.0.6 (969da088)
# + bun-types@1.0.7
# + @sinclair/typebox@0.31.18
# + elysia@0.7.17
# + ioredis@5.3.2
#
# 21 packages installed [38.00ms]
Then, use Bun to run the application:
bun dev
# $ bun run --watch src/index.ts
# Ad server API is running at localhost:3888
5. Interact with the Ad Server API
Now that the ad server API is running, we can interact with it using curl
or a similar tool.
For instance, we can create ads with the following requests:
curl --request POST \
--url http://localhost:3888/ads \
--header 'Content-Type: application/json' \
--data '{
"id": "1",
"title": "Dragonfly: An In-Memory Data Store Built for Modern Workloads",
"category": "technology",
"clickURL": "https://www.dragonflydb.io",
"imageURL": "https://www.dragonflydb.io/blog"
}'
curl --request POST \
--url http://localhost:3888/ads \
--header 'Content-Type: application/json' \
--data '{
"id": "2",
"title": "Dragonfly Cloud: Fully Managed Dragonfly Service",
"category": "technology",
"clickURL": "https://www.dragonflydb.io/cloud",
"imageURL": "https://www.dragonflydb.io/blog"
}'
Similarly, we can create or update user preferences with the following request:
curl --request POST \
--url http://localhost:3888/ads/user_preferences \
--header 'Content-Type: application/json' \
--data '{
"userId": "1",
"categories": [
"sports",
"technology"
]
}'
Finally, we can retrieve ads for a specific user with the following request.
Since the user with ID 1
has two preferences, sports
and technology
, we expect to see both technology
ads created above:
curl --request GET \
--url http://localhost:3888/ads/user_preferences/1
[
{
"id": "1",
"title": "Dragonfly: An In-Memory Data Store Built for Modern Workloads",
"category": "technology",
"clickURL": "https://www.dragonflydb.io",
"imageURL": "https://www.dragonflydb.io/blog"
},
{
"id": "2",
"title": "Dragonfly Cloud: Fully Managed Dragonfly Service",
"category": "technology",
"clickURL": "https://www.dragonflydb.io/cloud",
"imageURL": "https://www.dragonflydb.io/blog"
}
]
Please note that while the example ad server API provided does not adhere strictly to the RESTful style, it serves sufficiently for the purposes of our demonstration.
Implementation Details
Now that we have the ad server API up and running, let's take a look at some key places in the codebase.
1. Client Initialization
Dragonfly is fully compatible with the Redis RESP protocol, which is supported by many client libraries and SDKs. In this example, we use the ioredis package to interact with Dragonfly. TypeScript allows import-aliasing. Although this is not required, it can be helpful to make the code clearer as we are emphasizing the connection to Dragonfly:
import { Redis as Dragonfly } from 'ioredis'
const client = new Dragonfly()
Also, it is notable that we are not passing any connection parameters to the client constructor.
This is because we are running Dragonfly locally using Docker, as explained above, and the default connection address localhost:6379
is sufficient.
2. Key Name Management
Just like Redis, Dragonfly is a key-value in-memory data store.
Data is stored in Dragonfly as key-value pairs, where the key is a String
, and the value can be one of the supported data types (such as String
, Hash
, Set
, Sorted-Set
, etc.).
Key-value data stores are often categorized as NoSQL databases.
Millions or even billions of keys can be stored in Dragonfly, and thus it is good practice to use a naming convention for keys within your application.
A common practice is to use semicolon-separated key segments, where each segment represents a level of hierarchy:
export class AdMetadataStore {
private client: Dragonfly
static readonly AD_PREFIX = 'ad'
static readonly AD_METADATA_PREFIX = `${AdMetadataStore.AD_PREFIX}:metadata`
static readonly AD_CATEGORY_PREFIX = `${AdMetadataStore.AD_PREFIX}:category`
static readonly AD_USER_PREFERENCE_PREFIX = `${AdMetadataStore.AD_PREFIX}:user_preference`
// ...
// Sample: 'ad:metadata:1'
private metadataKey(metadataId: string): string {
return `${AdMetadataStore.AD_METADATA_PREFIX}:${metadataId}`
}
// Sample: 'ad:category:technology'
private categoryKey(metadataCategory: string): string {
return `${AdMetadataStore.AD_CATEGORY_PREFIX}:${metadataCategory}`
}
// Sample: 'ad:user_preference:1'
private userPreferenceKey(userId: string): string {
return `${AdMetadataStore.AD_USER_PREFERENCE_PREFIX}:${userId}`
}
// ...
}
Combining key prefixes and helper methods, we can easily construct keys for different purposes.
And whenever a value needs to be accessed (i.e., by userId
), we are confident that the key is constructed correctly.
3. Dragonfly Compatibility
As previously mentioned, Dragonfly is fully wire-protocol compatible and supports 220+ Redis commands.
The ioredis package we are using in this example provides strongly-typed methods for each of the supported commands.
If a command is supported by Dragonfly, then we can use the corresponding ioredis method directly.
For instance, in the createAdMetadata
method below, we use both the HMSET
command and the SADD
command.
By doing so, a piece of ad metadata information is stored as a hash value, and its ID is added to the corresponding category-set.
export class AdMetadataStore {
// ...
async createAdMetadata(metadata: AdMetadata): Promise<void> {
await this.client.hmset(this.metadataKey(metadata.id), metadata)
await this.client.sadd(this.categoryKey(metadata.category), metadata.id)
}
// ...
}
4. End-to-End Type Safety
The rest of the code example should be straightforward to follow, as explained in the Ad Serving Functionalities section above.
It is worth mentioning that Bun, ElysiaJS, and ioredis provide a great developer experience along with Dragonfly,
and we are able to leverage the power of these tools to ensure end-to-end type safety throughout the codebase.
For instance, each command (like HMSET
or SADD
) is already strongly-typed as shown above.
Another example can be illustrated by the handler for the POST /ads
endpoint below.
We are passing context.body
, which is originally of type unknown
, to the createAdMetadata
method.
Since we also specified an input schema hook { body: AdMetadata }
, ElysiaJS will magically and automatically validate the input and infer the type of context.body
as AdMetadata
.
const app = new Elysia()
.decorate('adMetadataCache', new AdMetadataStore(client))
.post(
'/ads',
async (context) => {
await context.adMetadataCache.createAdMetadata(context.body)
context.set.status = 201
return
},
{ body: AdMetadata } // Input Schema Hook
)
.listen(3888)
Conclusion
In this blog post, we guided you through the process of building a robust ad-serving application, demonstrating the seamless integration and high compatibility of Dragonfly within our tech stack.
While we have focused on the practical aspects of setting up and utilizing Dragonfly, it's important to note that we've only scratched the surface of its capabilities. Dragonfly offers plenty of advantages, most notably its exceptional performance:
- If you are interested in how Dragonfly can handle BigKeys smoothly (which is likely to happen in our ad-serving scenario as well), check out our handling BigKeys with Dragonfly blog post.
- You can also read more about the detailed 4 million QPS benchmark results in the scalability and performance blog post.
- Finally, if you have encountered problems with Redis snapshotting, check out how Dragonfly tackles them with its balanced snapshotting algorithm here.
Happy coding, and until our paths cross again in the very near future, let's keep building amazing things!