Flash Sale System
Overview
Flash sale system developed with Spring Boot, Spring JPA, RabbitMQ and Angular (Clarity) frontend.
Github: https://github.com/gitorko/project90
Quick Overview
To deploy the application in a single command, clone the project, make sure no conflicting docker containers or ports are running and then run
1git clone https://github.com/gitorko/project90
2cd project90
3docker-compose -f docker/docker-compose.yml up
Requirements
A flash sale system that supports one item per user and reserving the item in a large scale flash sale.
Functional Requirements
- A flash sale system goes live on a particular date & time. Users should not be able to add to cart before that.
- There will be large number of users requesting to add the item to the cart at the specific moment in time. The items will be limited in stock.
- The 'add to cart' action must be honored in the order they were received. The first user to click on add to cart must get the item.
- As long as there are products each user requesting should get the item in the cart.
- Once the item is added to the cart rest of the process of checkout is beyond scope of this demo.
- A user can get just one item they should not be able to buy more than one item.
- User must be able to delete the item from the cart after which it should be available for other users.
- The authentication can be mocked to randomly assign a user to each browser instance. So each time you open the url in a different browser/tab it is assigned a unique user.
- User can logout and it will assign a new user.
Non-Functional Requirements
- Latency to place the request should be low.
- System should be highly available & be able to handle burst of request traffic in short duration.
- System should scale well when number of users increases
Implementation
Design
- We will use a rabbitmq to queue the incoming burst of requests.
- Each request response time window will be kept as minimal as possible to avoid crashing the system under heavy load.
- Each user after placing the request to add to cart will be in wait state and query the status of his request.
- The backend and frontend bundle into a single uber jar that can be deployed on many servers there by providing ability to horizontally scale.
- The max limit of requests that can be served by a single instance then depend on the default tomcat thread pool size of 200 and the server configurations.
If the user tries to book the item before sale begin date, it will fail. Add to cart will work only after the sale begins.
If the same user tries to book the item in 2 tabs only one will succeed, one user can buy only 1 item in the sale.
Two users can try to book the item at the same time each will be alloted a different item if its available.
After adding item to cart user can remove the item from cart.
After adding an item the client waits for the action to complete.
Code
1package com.demo.project90.controller;
2
3import static com.demo.project90.config.Constant.ITEM_QUEUE;
4import static com.demo.project90.config.Constant.ITEM_SALE_NOT_STARTED_MSG;
5import static com.demo.project90.config.Constant.TOKEN_QUEUE;
6import static org.springframework.http.HttpStatus.NOT_FOUND;
7
8import java.time.Duration;
9import java.time.Instant;
10import java.util.UUID;
11
12import com.demo.project90.domain.Audit;
13import com.demo.project90.domain.Item;
14import com.demo.project90.model.QEvent;
15import com.demo.project90.model.QItem;
16import com.demo.project90.repo.AuditRepository;
17import com.demo.project90.repo.ItemRepository;
18import com.demo.project90.service.AuditService;
19import lombok.RequiredArgsConstructor;
20import lombok.extern.slf4j.Slf4j;
21import org.springframework.amqp.rabbit.connection.ConnectionFactory;
22import org.springframework.amqp.rabbit.core.RabbitTemplate;
23import org.springframework.web.bind.annotation.DeleteMapping;
24import org.springframework.web.bind.annotation.GetMapping;
25import org.springframework.web.bind.annotation.PathVariable;
26import org.springframework.web.bind.annotation.RestController;
27import org.springframework.web.server.ResponseStatusException;
28
29@RestController
30@Slf4j
31@RequiredArgsConstructor
32public class HomeController {
33
34 private final RabbitTemplate template;
35 private final ItemRepository itemRepo;
36 private final AuditRepository auditRepo;
37 private final AuditService auditService;
38 private final ConnectionFactory connectionFactory;
39
40 @GetMapping(value = "/api/user")
41 public String getUser() {
42 return UUID.randomUUID().toString().substring(0, 7);
43 }
44
45 @GetMapping(value = "/api/items/count")
46 public long getFreeItemCount() {
47 return itemRepo.countAllByCartOfIsNull();
48 }
49
50 @GetMapping(value = "/api/cart/items/{username}")
51 public Iterable<Item> getCartItems(@PathVariable String username) {
52 return itemRepo.findAllByCartOf(username);
53 }
54
55 @GetMapping(value = "/api/cart/{username}")
56 public QEvent addCartItem(@PathVariable String username) {
57 Instant start = Instant.now();
58 log.info("username: {}", username);
59 String token = UUID.randomUUID().toString();
60 QEvent qEvent = QEvent.builder()
61 .user(username)
62 .token(token)
63 .attemptCount(0)
64 .build();
65 if (!auditService.checkIfSaleStarted()) {
66 auditService.saveAudit(ITEM_SALE_NOT_STARTED_MSG, qEvent.getUser(), qEvent.getToken(), -1l, "FAIL");
67 Instant finish = Instant.now();
68 log.info("Request rejected in: {} ms", username, Duration.between(start, finish).toMillis());
69 return qEvent;
70 } else {
71 template.convertAndSend(TOKEN_QUEUE, qEvent);
72 Instant finish = Instant.now();
73 log.info("Add to cart for {} took: {} ms", username, Duration.between(start, finish).toMillis());
74 return qEvent;
75 }
76 }
77
78 @DeleteMapping(value = "/api/cart/{username}/{id}")
79 public boolean deleteCartItem(@PathVariable String username, @PathVariable Long id) {
80 itemRepo.findById(id).ifPresent(e -> {
81 //only user who owns the cart can delete
82 if (e.getCartOf().equals(username)) {
83 e.setCartOf(null);
84 e.setAddedOn(null);
85 itemRepo.save(e);
86 pushAvailableItem(QItem.builder().itemId(id).build());
87 }
88 });
89
90 return true;
91 }
92
93 @GetMapping(value = "/api/audit/{token}")
94 public Audit getTokenMessage(@PathVariable String token) {
95 if (auditRepo.findByToken(token).isPresent()) {
96 return auditRepo.findByToken(token).get();
97 } else {
98 throw new ResponseStatusException(NOT_FOUND, "token not found!");
99 }
100 }
101
102 private void pushAvailableItem(QItem qItem) {
103 template.convertAndSend(ITEM_QUEUE, qItem);
104 }
105}
1package com.demo.project90.queue;
2
3import static com.demo.project90.config.Constant.ITEM_ADDED_TO_CART_MSG;
4import static com.demo.project90.config.Constant.ITEM_ALREADY_IN_CART_MSG;
5import static com.demo.project90.config.Constant.ITEM_MISMATCH_MSG;
6import static com.demo.project90.config.Constant.ITEM_QUEUE;
7import static com.demo.project90.config.Constant.ITEM_SALE_NOT_STARTED_MSG;
8import static com.demo.project90.config.Constant.ITEM_SOLD_OUT_MSG;
9import static com.demo.project90.config.Constant.ITEM_TYPE;
10import static com.demo.project90.config.Constant.TOKEN_QUEUE;
11
12import java.time.LocalDateTime;
13
14import com.demo.project90.domain.Item;
15import com.demo.project90.model.QEvent;
16import com.demo.project90.model.QItem;
17import com.demo.project90.repo.AuditRepository;
18import com.demo.project90.repo.ItemRepository;
19import com.demo.project90.service.AuditService;
20import com.fasterxml.jackson.databind.ObjectMapper;
21import com.rabbitmq.client.Channel;
22import com.rabbitmq.client.GetResponse;
23import lombok.RequiredArgsConstructor;
24import lombok.SneakyThrows;
25import lombok.extern.slf4j.Slf4j;
26import org.springframework.amqp.rabbit.annotation.RabbitListener;
27import org.springframework.amqp.rabbit.connection.Connection;
28import org.springframework.amqp.rabbit.connection.ConnectionFactory;
29import org.springframework.stereotype.Component;
30
31@Component
32@Slf4j
33@RequiredArgsConstructor
34public class EventListener {
35
36 private final ItemRepository itemRepo;
37 private final AuditRepository auditRepo;
38 private final AuditService auditService;
39 private final ConnectionFactory connectionFactory;
40 private ObjectMapper objectMapper = new ObjectMapper();
41
42 @SneakyThrows
43 @RabbitListener(queues = TOKEN_QUEUE)
44 public void processRequest(QEvent qEvent) {
45 log.info("Received qEvent: {}", qEvent);
46 if (!auditService.checkIfSaleStarted()) {
47 auditService.saveAudit(ITEM_SALE_NOT_STARTED_MSG, qEvent.getUser(), qEvent.getToken(), -1l, "FAIL");
48 return;
49 }
50 //check if user already has item in cart.
51 if (itemRepo.countByCartOfAndType(qEvent.getUser(), ITEM_TYPE) == 0) {
52 //Find the first available item.
53 QItem qItem = popAvailableItem();
54 if (qItem == null) {
55 //sold out.
56 auditService.saveAudit(ITEM_SOLD_OUT_MSG, qEvent.getUser(), qEvent.getToken(), -1l, "FAIL");
57 return;
58 }
59 Item item = itemRepo.findByIdAndCartOfIsNull(qItem.getItemId());
60 if (item != null) {
61 //add to cart of user.
62 item.setCartOf(qEvent.getUser());
63 item.setAddedOn(LocalDateTime.now());
64 itemRepo.save(item);
65 auditService.saveAudit(String.format(ITEM_ADDED_TO_CART_MSG, item.getName()), qEvent.getUser(), qEvent.getToken(), item.getId(), "SUCCESS");
66 } else {
67 auditService.saveAudit(ITEM_MISMATCH_MSG, qEvent.getUser(), qEvent.getToken(), -1l, "FAIL");
68 }
69 } else {
70 //sold out.
71 auditService.saveAudit(ITEM_ALREADY_IN_CART_MSG, qEvent.getUser(), qEvent.getToken(), -1l, "FAIL");
72 }
73 }
74
75 @SneakyThrows
76 private QItem popAvailableItem() {
77 try (Connection connection = connectionFactory.createConnection()) {
78 Channel channel = connection.createChannel(true);
79 GetResponse resp = channel.basicGet(ITEM_QUEUE, true);
80 if (resp != null) {
81 String message = new String(resp.getBody(), "UTF-8");
82 return objectMapper.readValue(message, QItem.class);
83 }
84 return null;
85 }
86 }
87
88}
1package com.demo.project90.service;
2
3import static com.demo.project90.config.Constant.SALE_BEGINS_AFTER;
4
5import java.time.LocalDateTime;
6
7import com.demo.project90.domain.Audit;
8import com.demo.project90.repo.AuditRepository;
9import lombok.RequiredArgsConstructor;
10import lombok.extern.slf4j.Slf4j;
11import org.springframework.stereotype.Component;
12
13@Component
14@RequiredArgsConstructor
15@Slf4j
16public class AuditService {
17 private final AuditRepository auditRepo;
18
19 public void saveAudit(String message, String username, String token, Long itemId, String type) {
20 log.info(message);
21 //Note: Audit tables are always insert and no updates should happen.
22 auditRepo.save(Audit.builder()
23 .username(username)
24 .itemId(itemId)
25 .message(message)
26 .token(token)
27 .logDate(LocalDateTime.now())
28 .type(type)
29 .build());
30 }
31
32 public boolean checkIfSaleStarted() {
33 if (LocalDateTime.now().isAfter(SALE_BEGINS_AFTER)) {
34 return true;
35 } else {
36 return false;
37 }
38 }
39}
1<div class="content-container">
2 <div class="content-area">
3 <div class="clr-row">
4 <div class="clr-col-12">
5 <div class="alert-section">
6 <app-alert></app-alert>
7 </div>
8
9 <h2 style="text-align: center">Flash Sale: {{itemCount}} items available</h2>
10 <div style="text-align: center;">
11 <img src="assets/flashsale.png" width="200" height="200" style="text-align: center;">
12 <br/>
13 <clr-spinner [clrMedium]="true" *ngIf="spinner" id="spinner"></clr-spinner>
14 <p *ngIf="spinner"><b style="color:red;">Dont Refresh</b></p>
15 <button type="submit" class="btn btn-primary" (click)="addToCart()" *ngIf="showAddToCartButton"
16 id="addToCart">
17 Add to cart
18 </button>
19 </div>
20 <br/>
21 <br/>
22 <h2 style="text-align: center">My Cart</h2>
23 <clr-datagrid>
24 <clr-dg-column>Name</clr-dg-column>
25 <clr-dg-column>Price</clr-dg-column>
26 <clr-dg-column>Cart Of</clr-dg-column>
27 <clr-dg-column>Added On</clr-dg-column>
28 <clr-dg-column>Action</clr-dg-column>
29 <!-- structural directive -->
30 <clr-dg-row clr-dg-row *clrDgItems="let item of items">
31 <clr-dg-placeholder class="content-center">No Items in Cart!</clr-dg-placeholder>
32 <clr-dg-cell>{{item.name}}</clr-dg-cell>
33 <clr-dg-cell>{{item.price}}</clr-dg-cell>
34 <clr-dg-cell>{{item.cartOf}}</clr-dg-cell>
35 <clr-dg-cell>{{item.addedOn}}</clr-dg-cell>
36 <clr-dg-cell>
37 <cds-icon shape="trash" style="cursor: pointer; color: blue" (click)="deleteCartFor(item.id)">
38 </cds-icon>
39 </clr-dg-cell>
40 </clr-dg-row>
41
42 <clr-dg-footer>
43 <clr-dg-pagination #pagination [clrDgPageSize]="10">
44 <clr-dg-page-size [clrPageSizeOptions]="[10,20,50,100]">Items per page</clr-dg-page-size>
45 {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} of {{pagination.totalItems}} items
46 </clr-dg-pagination>
47 </clr-dg-footer>
48 </clr-datagrid>
49
50 </div>
51 </div>
52 </div>
53</div>
54
1import {Component, OnInit, ViewChild} from '@angular/core';
2import {RestService} from '../../services/rest.service';
3import {Router} from '@angular/router';
4import {AlertComponent} from '../alert/alert.component';
5import {ClarityIcons, trashIcon} from '@cds/core/icon';
6import {Item} from "../../models/item";
7
8@Component({
9 selector: 'app-home',
10 templateUrl: './home.component.html',
11 styleUrls: []
12})
13export class HomeComponent implements OnInit {
14
15 items: Item[] = [];
16 itemCount = 0;
17 // @ts-ignore
18 @ViewChild(AlertComponent, {static: true}) private alert: AlertComponent;
19 spinner = false;
20 showAddToCartButton = true;
21 token = '';
22
23 constructor(private restService: RestService, private router: Router) {
24 ClarityIcons.addIcons(trashIcon);
25 }
26
27 ngOnInit(): void {
28 this.refresh();
29 this.token = '';
30 }
31
32 refresh(): void {
33 this.getCartItems();
34 this.getItemCount();
35 }
36
37 getCartItems(): void {
38 const username = sessionStorage.getItem('user');
39 this.restService.getCartItems(username).subscribe(data => {
40 this.items = data;
41 if (this.items.length > 0) {
42 this.showAddToCartButton = false;
43 }
44 });
45 }
46
47 getItemCount(): void {
48 this.restService.getFreeItemCount().subscribe(data => {
49 this.itemCount = data;
50 if (this.itemCount === 0) {
51 this.showAddToCartButton = false;
52 }
53 });
54 }
55
56 addToCart(): void {
57 const username = sessionStorage.getItem('user');
58 this.showAddToCartButton = false;
59 this.spinner = true;
60 this.restService.addCartItem(username)
61 .subscribe(data => {
62 if (data) {
63 this.token = data.token;
64 this.alert.showSuccess('In Queue!');
65 } else {
66 this.alert.showError('Failed to enter Queue!');
67 }
68 this.checkIfComplete();
69 });
70 }
71
72 checkIfComplete(): void {
73 this.restService.getAuditToken(this.token)
74 .subscribe(data => {
75 if (data) {
76 if (data.type === 'SUCCESS') {
77 this.alert.showSuccess(data.message);
78 } else {
79 this.alert.showError(data.message);
80 }
81 this.refresh();
82 this.spinner = false;
83 }
84 },
85 error => {
86 setTimeout(
87 () => {
88 this.checkIfComplete();
89 },
90 5000
91 );
92 });
93 }
94
95 deleteCartFor(id: any): void {
96 const username = sessionStorage.getItem('user');
97 this.restService.deleteCartItem(username, id)
98 .subscribe(data => {
99 if (data) {
100 this.items = [];
101 this.alert.showSuccess('Deleted from cart!');
102 } else {
103 this.alert.showError('Failed to delete from cart!');
104 }
105 this.refresh();
106 });
107 }
108
109}
Setup
1# Project 90
2
3Flash Sale + RabbitMQ + Postgres + Jmeter
4
5[https://gitorko.github.io/flash-sale-system/](https://gitorko.github.io/flash-sale-system/)
6
7### Version
8
9Check version
10
11```bash
12$java --version
13openjdk version "21.0.3" 2024-04-16 LTS
14
15node --version
16v16.16.0
17
18yarn --version
191.22.18
20```
21
22### Postgres DB
23
24```
25docker run -p 5432:5432 --name pg-container -e POSTGRES_PASSWORD=password -d postgres:9.6.10
26docker ps
27docker exec -it pg-container psql -U postgres -W postgres
28CREATE USER test WITH PASSWORD 'test@123';
29CREATE DATABASE "test-db" WITH OWNER "test" ENCODING UTF8 TEMPLATE template0;
30grant all PRIVILEGES ON DATABASE "test-db" to test;
31
32docker stop pg-container
33docker start pg-container
34```
35
36### RabbitMQ
37
38```
39docker run -d -p 5672:5672 -p 15672:15672 --name my-rabbit rabbitmq:3-management
40```
41
42Open [http://localhost:15672/](http://localhost:15672/)
43
44```bash
45user: guest
46pwd: guest
47```
48
49### Dev
50
51To run the backend in dev mode Postgres DB is needed to run the integration tests during build.
52
53```bash
54./gradlew clean build
55./gradlew bootRun
56```
57
58To Run UI in dev mode
59
60```bash
61cd ui
62yarn install
63yarn build
64yarn start
65```
66
67Open [http://localhost:4200/](http://localhost:4200/)
68
69### Prod
70
71To run as a single jar, both UI and backend are bundled to single uber jar.
72
73```bash
74./gradlew cleanBuild
75cd build/libs
76java -jar project90-1.0.0.jar
77```
78
79Open [http://localhost:8080/](http://localhost:8080/)
80
81### JMeter & Selenium
82
83To test for concurrent requests and load test the UI you can use JMeter with selenium plugin
84
85```bash
86brew install jmeter
87xattr -d com.apple.quarantine chromedriver
88```
89
90Install the selenium plugin for JMeter
91
92[https://jmeter-plugins.org/](https://jmeter-plugins.org/)
93
94Download the chrome driver
95
96[https://chromedriver.chromium.org/downloads](https://chromedriver.chromium.org/downloads)
97
98### Docker
99
100```bash
101./gradlew cleanBuild
102docker build -f docker/Dockerfile --force-rm -t project90:1.0.0 .
103docker images |grep project90
104docker tag project90:1.0.0 gitorko/project90:1.0.0
105docker push gitorko/project90:1.0.0
106docker-compose -f docker/docker-compose.yml up
107```
Testing
Click on start button to test multiple requests to add to cart.
The resources of the system are
Tomcat server has default 200 worker threads. Each 'add to cart' request takes average 20 ms for the above resources. 200/0.02 = 10,000 requests can be handled per second. Reducing this by load factor due to GC and context switching of 0.8 (80%) gives us 10000 x 0.8 = 8000 requests per second.
This setup can be deployed on multi node scenario, as the tokens are fetched from RabbitMQ queue it will scale on a distributed setup. Further optimization can be done by having region dedicated queue sharding and region specific event processor.
For authenticated sessions DOS (Denial Of Service) attacks are not a concern, if you still need to ensure against DOS attacks you can use a Captcha.
Once you add more servers to handle the request the bottleneck shifts to RabbitMQ capability to handle load and we can then explore clustering in RabbitMQ. The queue can be made persistent so that events survive a restart.
References
https://spring.io/projects/spring-boot
https://hackernoon.com/developing-a-flash-sale-system-7481f6ede0a3