Building a Real-Time Ad Server with Dragonfly
Tags: redis, database
Published: 2023-11-14 17:00:00

## 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](https://bun.sh/), 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](https://elysiajs.com/) is a TypeScript framework supercharged by Bun with end-to-end type safety and outstanding developer experience.
- Last but not least, [Dragonfly](https://github.com/dragonflydb/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](https://github.com/dragonflydb/dragonfly-examples/tree/main/ad-server-cache-bun) 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 delve 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.

```shell
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.

```shell
dragonfly$> SMEMBERS ad:category:technology
1) "1"
2) "2"
```

```shell
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](https://www.docker.com/products/docker-desktop/) is installed and running locally,
  which will be used to run Dragonfly.
- Make sure [Bun](https://bun.sh/) 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](https://www.dragonflydb.io/docs/getting-started).
For this blog post, we will be using Docker to run Dragonfly with just one command:

```shell
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:

```text
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](https://github.com/dragonflydb/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.

```shell
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.

```shell
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:

```shell
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:

```shell
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:

```shell
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:

```shell
curl --request GET \
  --url http://localhost:3888/ads/user_preferences/1
```

```json
[
  {
    "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](https://redis.io/docs/reference/protocol-spec/), which is supported by many client libraries and SDKs.
In this example, we use the [ioredis](https://github.com/redis/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:

```typescript
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:

```typescript
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.

```typescript
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](#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`.

```typescript
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](https://www.dragonflydb.io/blog/fearlessly-handling-bigkeys-with-dragonfly).
- You can also read more about the detailed 4 million QPS benchmark results in the scalability and performance [blog post](https://www.dragonflydb.io/blog/scaling-performance-redis-vs-dragonfly).
- Finally, if you have encountered problems with Redis snapshotting, check out how Dragonfly tackles them with its balanced snapshotting algorithm [here](https://www.dragonflydb.io/blog/balanced-vs-unbalanced).

Happy coding, and until our paths cross again in the very near future, let's keep building amazing things!