Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#PSG_sql
DB_PORT=5432
POSTGRES_DB=fairdrop_db
POSTGRES_USER=fairdrop_user
POSTGRES_PASSWORD=fairdrop_password
DB_HOST=db_fairdrop
#REDIS
REDIS_PREFIX=redis://
REDIS_HOST=redis_fairdrop
REDIS_PORT=6379
REDIS_URL=${REDIS_PREFIX}${REDIS_HOST}:${REDIS_PORT}
#s3
S3_HOST=s3-fairdrop
S3_PORT=9000
MINIO_ROOT_USER=s3_fairdrop_user
MINIO_ROOT_PASSWORD=s3_fairdrop_password
MINIO_BUCKET_NAME=s3_fairdrop-media
MINIO_URL=http://${S3_HOST}:${S3_PORT}
PRESIGNED_URL_EXPIRE_SECONDS=3600
MIN_FILE_SIZE_BYTES=1
MAX_FILE_SIZE_BYTES=5242880
#gateway
GATEWAY_HOST=gateway_fairdrop
GATEWAY_PORT=8080
#app
APP_PORT=8000
#debug
DEBUG_MODE=True
#jwt
SECRET_KEY=change_me_super_secret
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
#lua limiter
RATE_LIMIT_USER_RPS=10
RATE_LIMIT_GLOBAL_RPS=1000
RATE_LIMIT_TTL_SECONDS=1
IDEMPOTENT_KEY_LIFETIME_SEC=86400
RESERVE_TIMEOUT_MINUTES=15
#db_engine_layer
POOL_SIZE=50
MAX_OVERFLOW=100
25 changes: 24 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,36 @@ on:
jobs:
build:
runs-on: ubuntu-latest
# =====
# Hardcode will be replaced with secrets GitHub Repo
# =====
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: fairdrop_user
POSTGRES_PASSWORD: fairdrop_password
POSTGRES_DB: fairdrop_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
DB_HOST: localhost # override docker container name from .env
# =====
# Hardcode will be replaced with secrets GitHub Repo
# =====
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up UV (without pip)
uses: astral-sh/setup-uv@v5
with:
with:
enable-cache: true

- name: Install dependencies
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ wheels/
.vscode/

# Local env
.env
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repos:
hooks:
- id: pytest
name: pytest
entry: pytest
entry: uv run pytest
language: system
pass_filenames: false
args: [tests/]
Expand Down
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
HOST ?= http://localhost:8080

.PHONY: stress-test oversell-test

stress-test:
uv run locust \
-f locustfile.py \
--headless \
--users 500 \
--spawn-rate 100 \
--run-time 60s \
--host $(HOST)

oversell-test:
DB_HOST=localhost uv run python scripts/seed_oversell_product.py
uv run locust \
-f locustfile_oversell.py \
--headless \
--users 100 \
--spawn-rate 50 \
--run-time 60s \
--host $(HOST)
15 changes: 13 additions & 2 deletions app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
from datetime import UTC, datetime, timedelta

from jose import jwt
Expand All @@ -8,14 +9,24 @@
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')


def verify_password(plain_password: str, hashed_password: str) -> bool:
def verify_password_sync(plain_password: str, hashed_password: str) -> bool:
return bool(pwd_context.verify(plain_password, hashed_password))


def get_password_hash(password: str) -> str:
async def verify_password(plain_password: str, hashed_password: str) -> bool:
return await asyncio.to_thread(
verify_password_sync, plain_password, hashed_password
)


def get_password_hash_sync(password: str) -> str:
return str(pwd_context.hash(password))


async def get_password_hash(password: str) -> str:
return await asyncio.to_thread(get_password_hash_sync, password)


def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
if expires_delta:
Expand Down
14 changes: 10 additions & 4 deletions app/services/inventory/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ class Product(Base):
default=Decimal('0.10'),
)
qty_available: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

__table_args__ = (
Expand All @@ -48,5 +50,9 @@ class Reservation(Base):
order_id: Mapped[UUID | None] = mapped_column(
ForeignKey('orders.id'), nullable=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
6 changes: 3 additions & 3 deletions app/services/inventory/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_session
from app.services.inventory.models import Reservation
from app.services.inventory.rate_limit import check_rate_limit
from app.services.inventory.schemas import ReservationCreate, ReservationResponse
from app.services.inventory.service import reserve_items
Expand All @@ -21,15 +20,16 @@ async def reservation_data(
x_idempotency_key: str = Header(...),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> Reservation:
) -> ReservationResponse:
await check_rate_limit(
rate_limit_script=request.app.state.rate_limit_script,
user_id=str(current_user.id),
item_id=str(reservation_data.product_id),
)
return await reserve_items(
result = await reserve_items(
session=session,
user_id=current_user.id,
idempotency_key=x_idempotency_key,
reservation_data=reservation_data,
)
return ReservationResponse.model_validate(result)
2 changes: 1 addition & 1 deletion app/services/inventory/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ class ReservationResponse(BaseModel):
id: UUID
product_id: UUID
user_id: UUID
quantity: int
quantity: int = Field(validation_alias='qty_reserved')
status: str
expires_at: datetime
4 changes: 2 additions & 2 deletions app/services/user/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def create_user(session: AsyncSession, user_create: UserCreate) -> User:
)
if result.scalar_one_or_none():
raise UserAlreadyExists
hashed_password = get_password_hash(user_create.password)
hashed_password = await get_password_hash(user_create.password)
user = User(email=user_create.email, password_hash=hashed_password)
session.add(user)
await session.commit()
Expand All @@ -36,7 +36,7 @@ async def authenticate_user(
) -> User | None:
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.password_hash):
if not user or not await verify_password(password, user.password_hash):
return None
return user

Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
postgres:
image: postgres:15-alpine
container_name: ${DB_HOST}
command: postgres -c max_connections=300 # switch to pgbouncer available
healthcheck:
test: ['CMD', 'pg_isready', '-d', '${POSTGRES_DB}', '-U', '${POSTGRES_USER}']
interval: 10s
Expand Down
49 changes: 49 additions & 0 deletions locust_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from http import HTTPStatus
from uuid import uuid4

from locust import HttpUser


class BaseUser(HttpUser):
"""
Base class for all load testing scenarios.
Contains registration and authorization logic.
Descendants define wait_time and @task methods.
"""

abstract = True

def on_start(self) -> None:
self.access_token: str | None = None
email = f'locust_{uuid4()}@mail.com'
password = '12345'

with self.client.post(
'/api/v1/users',
json={'email': email, 'password': password},
catch_response=True,
) as reg_res:
if reg_res.status_code not in (
HTTPStatus.OK,
HTTPStatus.CREATED,
HTTPStatus.BAD_REQUEST,
):
reg_res.failure(f'Registration failed: {reg_res.status_code}')
return

with self.client.post(
'/api/v1/auth/token',
data={'username': email, 'password': password},
catch_response=True,
) as token_res:
if token_res.status_code == HTTPStatus.OK:
self.access_token = token_res.json().get('access_token')
else:
token_res.failure(f'Login failed: {token_res.status_code}')

@property
def auth_headers(self) -> dict[str, str]:
return {
'Authorization': f'Bearer {self.access_token}',
'X-Idempotency-Key': str(uuid4()),
}
30 changes: 30 additions & 0 deletions locustfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from http import HTTPStatus

from locust import between, task

from locust_base import BaseUser

TARGET_PRODUCT_ID = '5995fa75-07c7-4b55-82b7-6bfbb52948b8'


class HighLoadUser(BaseUser):
wait_time = between(0.5, 2.0)

@task
def reserve_product(self) -> None:
if not self.access_token:
return
with self.client.post(
'/api/v1/inventory/reserve',
headers=self.auth_headers,
json={'product_id': TARGET_PRODUCT_ID, 'quantity': 1},
catch_response=True,
) as reserve_res:
if reserve_res.status_code not in (
HTTPStatus.OK,
HTTPStatus.CREATED,
HTTPStatus.CONFLICT,
HTTPStatus.TOO_MANY_REQUESTS,
HTTPStatus.BAD_REQUEST,
):
reserve_res.failure(f'Reserve failed: {reserve_res.status_code}')
30 changes: 30 additions & 0 deletions locustfile_oversell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from http import HTTPStatus

from locust import between, task

from locust_base import BaseUser

OVERSELL_PRODUCT_ID = '3fe44185-589a-4703-b640-40df8d7ea67f'


class OversellTestUser(BaseUser):
wait_time = between(0.1, 0.5)

@task
def reserve_oversell_product(self) -> None:
if not self.access_token:
return
with self.client.post(
'/api/v1/inventory/reserve',
headers=self.auth_headers,
json={'product_id': OVERSELL_PRODUCT_ID, 'quantity': 1},
catch_response=True,
) as reserve_res:
if reserve_res.status_code not in (
HTTPStatus.OK,
HTTPStatus.CREATED,
HTTPStatus.CONFLICT,
HTTPStatus.TOO_MANY_REQUESTS,
HTTPStatus.BAD_REQUEST,
):
reserve_res.failure(f'Reserve failed: {reserve_res.status_code}')
Loading