- Handle Flash Sale With Easegress and WebAssembly
A flash sale is a discount or promotion offered by an eCommerce store for a short period. The quantity is limited, which often means the discounts are higher or more significant than run-of-the-mill promotions.
However, significant discounts, limited quantity, and a short period leading to a significant high traffic spike, which often results in slow service, denial of service, or even downtime.
This document illustrates how to leverage the WasmHost Filter to protect the backend service in a flash sale. The WebAssembly code is written in AssemblyScript by using the Easegress AssemblyScript SDK.
Before we start, we need to introduce why we use a service gateway with WebAssembly. Firstly, Easegress as a service gateway is more responsible for the control logic. Secondly, the business logic like the flash sale would be a more customized thing and could be changed frequently. Using Javascript or other high-level languages to write business logic could bring good productivity and lower technical barriers. With WebAssembly technology, the high-level languages code can be compiled to WASM and loaded dynamically at runtime. Furthermore, the WebAssembly code has good enough performance and security. So this combination can provide a perfect solution in terms of security, high performance, and customization extensions.
Please ensure a recent version of Git, Golang, Node.js and its package manager npm are installed before continue. Basic knowledge about writing and working with TypeScript modules, which is very similar to AssemblyScript, is a plus.
Note: The WasmHost
filter is disabled by default. To enable it, you need to build Easegress with the below command:
$ make build_server GOTAGS=wasmhost
1 ) Clone git repository easegress-assemblyscript-sdk
to somewhere on disk
$ git clone https://github.com/megaease/easegress-assemblyscript-sdk.git
2 ) Switch to a new directory and initialize a new node module:
npm init
3 ) Install the AssemblyScript compiler using npm, assume that the compiler is not required in production, and make it a development dependency:
npm install --save-dev assemblyscript
4 ) Once installed, the compiler provides a handy scaffolding utility to quickly set up a new AssemblyScript project, for example, in the directory of the just initialized node module:
npx asinit .
5 ) Add --use abort=
to the asc
in package.json
, for example:
"asbuild:untouched": "asc assembly/index.ts --target debug --use abort=",
"asbuild:optimized": "asc assembly/index.ts --target release --use abort=",
6 ) Replace the content of assembly/index.ts
with the code below, note to replace {EASEGRESS_SDK_PATH}
with the path in step 1). The code is just a skeleton and does "nothing" at present, it will be enhanced later:
// this line exports everything required by Easegress,
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy'
// import everything you need from the SDK,
import { Program, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress'
// define the program, 'FlashSale' is the name
class FlashSale extends Program {
// constructor is the initializer of the program, will be called once at the startup
constructor(params: Map<string, string>) {
super(params)
}
// run will be called for every request
run(): i32 {
return 0
}
}
// register a factory method of the FlashSale program
registerProgramFactory((params: Map<string, string>) => {
return new FlashSale(params)
})
7 ) Build with the below command, if everything is right, untouched.wasm
(the debug version) and optimized.wasm
(the release version) will be generated at the build
folder.
$ npm run asbuild
Create an HTTPServer in Easegress to listen on port 10080 to handle the HTTP traffic:
$ echo '
kind: HTTPServer
name: http-server
port: 10080
keepAlive: true
https: false
rules:
- paths:
- pathPrefix: /flashsale
backend: flash-sale-pipeline' | egctl create -f -
Create pipeline flash-sale-pipeline
which includes a WasmHost
filter:
$ echo '
name: flash-sale-pipeline
kind: Pipeline
flow:
- filter: wasm
- filter: mock
filters:
- name: wasm
kind: WasmHost
maxConcurrency: 2
code: /home/megaease/example/build/optimized.wasm
timeout: 100ms
- name: mock
kind: Mock
rules:
- body: "You can buy the laptop for $1 now.\n"
code: 200' | egctl create -f -
Note to replace /home/megaease/example/build/optimized.wasm
with the path of the file generated in step 7) of section 1.1.
In the above pipeline configuration, a Mock
filter is used as the backend service. In practice, you will need a Proxy
filter to forward requests to the real backend.
Execute the below command from a new console, you should get a similar result if everything is right:
$ curl https://127.0.0.1:10080/flashsale
You can buy the laptop for $1 now.
All flash sale promotions have a start time, requests before the time should be blocked. Suppose the start time is UTC 2021-08-08 00:00:00
, this can be accomplished with the below code:
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy'
import { Program, response, parseDate, getUnixTimeInMs, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress'
class FlashSale extends Program {
// startTime is the start time of the flash sale, unix timestamp in millisecond
startTime: i64
constructor(params: Map<string, string>) {
super(params)
this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime()
}
run(): i32 {
// if flash sale not start yet
if (getUnixTimeInMs() < this.startTime) {
// we just set response body to 'not start yet' here, in practice,
// we will use 'response.setStatusCode(302)' to redirect user to
// a static page.
response.setBody(String.UTF8.encode("not start yet.\n"))
return 1
}
return 0
}
}
registerProgramFactory((params: Map<string, string>) => {
return new FlashSale(params)
})
Build and inform Easegress to reload with:
$ npm run asbuild
$ egctl wasm reload-code
curl
the flash sale URL, we will get not start yet.
before the start time of the flash sale.
$ curl https://127.0.0.1:10080/flashsale
not start yet.
After the start of the flash sale, Easegress should block requests randomly, this greatly reduces the total number of requests sent to the backend service, which protects the service from the strike of the traffic spike. The randomness brings another benefit also: geography differences result in latency differences, users with a lower latency are more likely to be the early users. The randomness removes the edge of these users and makes the flash sale fairer.
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy'
import { Program, response, parseDate, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress'
class FlashSale extends Program {
startTime: i64
// blockRatio is the ratio of requests being blocked to protect backend service
// for example: 0.4 means we blocks 40% of the requests randomly.
blockRatio: f64
constructor(params: Map<string, string>) {
super(params)
this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime()
this.blockRatio = 0.4
}
run(): i32 {
if (getUnixTimeInMs() < this.startTime) {
response.setBody(String.UTF8.encode("not start yet.\n"))
return 1
}
if (rand() > this.blockRatio) {
// the lucky guy
return 0
}
// block this request, set response body to `sold out`
response.setBody(String.UTF8.encode("sold out.\n"))
return 2
}
}
registerProgramFactory((params: Map<string, string>) => {
return new FlashSale(params)
})
Build and verify with (suppose the flash sale was already started):
$ npm run asbuild
$ egctl wasm apply-data --reload-code
$ curl https://127.0.0.1:10080/flashsale
sold out.
$ curl https://127.0.0.1:10080/flashsale
You can buy the laptop for $1 now.
$ curl https://127.0.0.1:10080/flashsale
sold out.
We will get a sold out
message at the possibility of 40%. Note the blockRatio
is 0.4
in this example, while in practice, 0.999
, 0.9999
will be much more make sense.
From the view of business, after we permit a lucky user to go forward, we should always permit this user to go forward; but from the logic of the code in the last step, the request may be blocked if the user accesses the URL again.
Fortunately, all users need to sign in before joining the flash sale, that's the requests will contain an identifier of the user, we can use this identifier to record the lucky users.
As an example, we suppose the value of the Authorization
header is the desired identifier (the identifier could be a JWT token, and the Validator filter can be used to validate the token, but this is out of the scope of this document).
However, due to the maxConcurrency
option in the filter configuration, using a Set
the store all permitted users won't work.
maxConcurrency
is the number of WebAssembly VMs of the WasmHost
filter, and because WebAssembly is designed to be safe, two VMs can not share data even if they are executing the same copy of code. That is, after VM1 permits a user, if the next request of the user is processed by VM2, it could be blocked. This could also happen when Easegress is deployed as a cluster.
To overcome this issue, Easegress provide APIs to access shared data:
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy'
import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress'
class FlashSale extends Program {
startTime: i64
blockRatio: f64
constructor(params: Map<string, string>) {
super(params)
this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime()
this.blockRatio = 0.4
}
run(): i32 {
if (getUnixTimeInMs() < this.startTime) {
response.setBody(String.UTF8.encode("not start yet.\n"))
return 1
}
// check if the user was already permitted
let id = request.getHeader("Authorization")
if (cluster.getString("id/" + id) == "true") {
return 0
}
if (rand() > this.blockRatio) {
// add the lucky guy to permitted users
cluster.putString("id/" + id, "true")
return 0
}
response.setBody(String.UTF8.encode("sold out.\n"))
return 2
}
}
registerProgramFactory((params: Map<string, string>) => {
return new FlashSale(params)
})
Build and verify with:
$ npm run asbuild
$ egctl wasm apply-data --reload-code
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user1
sold out.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user1
You can buy the laptop for $1 now.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user1
You can buy the laptop for $1 now.
Repeat the curl
command, we will find out that the user will never be blocked again after he/she was permitted for the first time.
As the quantity is often limited in a flash sale, we can block users after we have permitted a number of users. For example, if the quantity is 10, permit 100 users is enough in most cases:
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy'
import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress'
class FlashSale extends Program {
startTime: i64
blockRatio: f64
// maxPermission is the upper limits of permitted users
maxPermission: i32
constructor(params: Map<string, string>) {
super(params)
this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime()
this.blockRatio = 0.4
this.maxPermission = 3
}
run(): i32 {
if (getUnixTimeInMs() < this.startTime) {
response.setBody(String.UTF8.encode("not start yet.\n"))
return 1
}
let id = request.getHeader("Authorization")
if (cluster.getString("id/" + id) == "true") {
return 0
}
// check the count of identifiers to see if we have reached the upper limit
if (cluster.countKey("id/") < this.maxPermission) {
if (rand() > this.blockRatio) {
cluster.putString("id/" + id, "true")
return 0
}
}
response.setBody(String.UTF8.encode("sold out.\n"))
return 2
}
}
registerProgramFactory((params: Map<string, string>) => {
return new FlashSale(params)
})
Build and verify with:
$ npm run asbuild
$ egctl wasm apply-data --reload-code
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user1
You can buy the laptop for $1 now.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user2
sold out.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user2
You can buy the laptop for $1 now.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user3
You can buy the laptop for $1 now.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user4
sold out.
$ curl https://127.0.0.1:10080/flashsale -HAuthorization:user4
sold out.
After 3 users were permitted, the 4th user is blocked forever.
We have hard-coded startTime
, blockRatio
, and maxPermission
in the above examples, which means if we have another flash sale, we need to modify the code. This is not a good practice.
A better approach is putting these parameters into the configuration:
filters:
- name: wasm
kind: WasmHost
parameters: # +
startTime: "2021-08-08T00:00:00+00:00" # +
blockRatio: "0.4" # +
maxPermission: "3" # +
And then revise the constructor
of the program to read in these parameters:
constructor(params: Map<string, string>) {
super(params)
let key = "startTime"
if (params.has(key)) {
let val = params.get(key)
this.startTime = parseDate(val).getTime()
}
key = "blockRatio"
if (params.has(key)) {
let val = params.get(key)
this.blockRatio = parseFloat(val)
}
key = "maxPermission"
if (params.has(key)) {
let val = params.get(key)
this.maxPermission = i32(parseInt(val))
}
}
As we can see in Lucky Once, Lucky Always, shared data is useful, but when reusing the code and configuration for a new flash sale event, the legacy data could cause problems. Easegress provides commands to manage these data.
We can view current data with (where flash-sale-pipeline
is the pipeline name and wasm
is the filter name):
$ egctl wasm list-data flash-sale-pipeline wasm
id/user1: "true"
id/user2: "true"
id/user3: "true"
Update the data with:
$ echo '
id/user4: "true"
id/user5: "true"' | egctl wasm apply-data flash-sale-pipeline wasm
$ egctl wasm list-data flash-sale-pipeline wasm
id/user1: "true"
id/user2: "true"
id/user3: "true"
id/user4: "true"
id/user5: "true"
And delete all data with:
$ egctl wasm delete-data flash-sale-pipeline wasm
$ egctl wasm list-data flash-sale-pipeline wasm
{}
Well, the above is all technical details. You can freely use the code to customize your business logic. However, it should be aware that the above is just a demo, and the practical solution is more complicated because it also needs to filter the crawlers and hackers. If you need a more professional solution, welcome to contact us.
Using WebAssembly's security, high performance, and real-time dynamic loading capabilities, we can not only do such a high concurrency business on the gateway but also achieve some more complex business logic support. Because WebAssembly can reuse a variety of high-level languages (such as Javascript, C/C++, Rust, Python, C#, etc.). Easegress has more capabilities to play in a high-performance traffic orchestration with distributed architecture. Both of there bring much imagination to solve the problem with efficient operation and maintenance.