Skip to content

Commit 4500bb1

Browse files
committed
* Multiple performance enahncements for Flagstore 2
1 parent 26f540d commit 4500bb1

File tree

18 files changed

+265
-209
lines changed

18 files changed

+265
-209
lines changed

checker/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ faker
55
httpx
66
python_dateutil==2.8.2
77
python-jose
8+
aiofiles

checker/src/checker.py

Lines changed: 113 additions & 126 deletions
Large diffs are not rendered by default.

checker/src/goblin.jpg

-201 KB
Loading

service/backend/Dockerfile

100644100755
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ WORKDIR /app
55

66
COPY requirements.txt .
77
RUN pip install --no-cache-dir -r requirements.txt
8-
RUN apt-get update && apt-get install -y netcat-openbsd sqlite3 && rm -rf /var/lib/apt/lists/*
8+
RUN apt-get clean && apt-get update && apt-get install -y netcat-openbsd sqlite3 && rm -rf /var/lib/apt/lists/*
99

1010
COPY generate_secret.sh .
1111

@@ -19,4 +19,4 @@ RUN chmod +x start.sh
1919
EXPOSE 2626
2020

2121
ENTRYPOINT ["./start.sh"]
22-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "2626"]
22+
CMD [ "gunicorn", "-c", "gunicorn.conf.py", "main:app" ]

service/backend/__init__.py

100644100755
File mode changed.

service/backend/auth.py

100644100755
File mode changed.

service/backend/crud.py

100644100755
Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import schemas
44
from auth import get_password_hash
55
import datetime
6+
from sqlalchemy.orm import joinedload
67

78

89
def get_user_by_username(db: Session, username: str):
@@ -26,13 +27,16 @@ def get_users(db: Session, skip: int = 0, limit: int = 10):
2627

2728

2829
def get_items_by_user(db: Session, user_id: int):
29-
user = db.query(models.User).filter(models.User.id == user_id).first()
30+
user = db.query(models.User).options(joinedload(models.User.items)).filter(models.User.id == user_id).first()
3031
return user.items if user else []
3132

32-
3333
def get_user_by_id(db: Session, user_id: int):
34-
return db.query(models.User).filter(models.User.id == user_id).first()
35-
34+
return (
35+
db.query(models.User)
36+
.filter(models.User.id == user_id)
37+
.options(joinedload(models.User.items))
38+
.first()
39+
)
3640

3741
def update_item_note(db: Session, item_id: int, note: str):
3842
item = db.query(models.Item).filter(models.Item.id == item_id).first()

service/backend/database.py

100644100755
File mode changed.

service/backend/gunicorn.conf.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import multiprocessing
2+
3+
worker_class = "uvicorn.workers.UvicornWorker"
4+
workers = min(4, multiprocessing.cpu_count())
5+
bind = "0.0.0.0:2626"
6+
timeout = 90
7+
keepalive = 3600
8+
preload_app = True

service/backend/main.py

100644100755
Lines changed: 123 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
from contextlib import asynccontextmanager
77
from datetime import datetime, timedelta
88
from typing import List
9+
10+
import aiofiles
911
from fastapi import FastAPI, Depends, HTTPException, Path, Body, status
12+
from sqlalchemy.orm import Session
1013
from sqlalchemy import func
11-
from sqlalchemy.orm import Session, joinedload
12-
from sqlalchemy import exists
13-
from database import SessionLocal, engine, Base
14+
from database import SessionLocal, engine
1415
import models
1516
import schemas
1617
import crud
@@ -152,10 +153,22 @@ def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db:
152153
@app.get("/users/me", response_model=schemas.User)
153154
def read_users_me(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
154155
username = get_username_from_token(token)
155-
user = crud.get_user_by_username(db, username=username)
156-
if user is None:
156+
user_data = (
157+
db.query(
158+
models.User,
159+
func.sum(models.Item.bonus).label('item_power')
160+
)
161+
.outerjoin(models.User.items)
162+
.filter(models.User.username == username)
163+
.group_by(models.User.id)
164+
.first()
165+
)
166+
167+
if not user_data:
157168
raise HTTPException(status_code=401, detail="User not found")
158-
user.power = user.power + sum(item.bonus for item in user.items)
169+
170+
user, item_power = user_data
171+
user.power = user.power + (item_power or 0)
159172
return user
160173

161174

@@ -171,32 +184,47 @@ def create_item_for_user(item: schemas.ItemCreate, token: str = Depends(oauth2_s
171184
@app.get("/users/", response_model=list[schemas.User])
172185
def read_users(
173186
db: Session = Depends(get_db),
174-
limit: int = 30
187+
limit: int = 30,
188+
offset: int = 0
175189
):
176-
zehn_min_ago = datetime.utcnow() - timedelta(minutes=10)
177-
users = (
178-
db.query(models.User)
179-
.options(joinedload(models.User.items))
180-
.all()
190+
users_query = (
191+
db.query(
192+
models.User,
193+
func.sum(models.Item.bonus).label('item_power')
194+
)
195+
.outerjoin(models.User.items)
196+
.group_by(models.User.id)
197+
.order_by(models.User.created_at.desc())
198+
.offset(offset)
199+
.limit(limit)
181200
)
182-
user_dicts = []
183-
for user in users:
184-
base_power = user.power
185-
items_power = sum(item.bonus for item in user.items)
186-
user_dict = user.__dict__.copy()
187-
user_dict["power"] = base_power + items_power
188-
user_dict["items"] = []
189-
user_dicts.append(user_dict)
190-
users = sorted(user_dicts, key=lambda u: u["created_at"], reverse=True)
191-
return users[:limit]
201+
202+
users_with_power = users_query.all()
203+
204+
result_users = []
205+
for user, item_power in users_with_power:
206+
user.power = user.power + (item_power or 0)
207+
result_users.append(user)
208+
return result_users
192209

193210

194211
@app.get("/users/{user_id}", response_model=schemas.User)
195212
def get_user_by_id(user_id: int = Path(...), db: Session = Depends(get_db)):
196-
user = crud.get_user_by_id(db, user_id)
197-
if not user:
213+
user_data = (
214+
db.query(
215+
models.User,
216+
func.sum(models.Item.bonus).label('item_power')
217+
)
218+
.outerjoin(models.User.items)
219+
.filter(models.User.id == user_id)
220+
.group_by(models.User.id)
221+
.first()
222+
)
223+
if not user_data:
198224
raise HTTPException(status_code=404, detail="User not found")
199-
user.power = user.power + sum(item.bonus for item in user.items)
225+
226+
user, item_power = user_data
227+
user.power = user.power + (item_power or 0)
200228
user.items = []
201229
return user
202230

@@ -207,7 +235,9 @@ def open_lootbox(token: str = Depends(oauth2_scheme), db: Session = Depends(get_
207235
user = crud.get_user_by_username(db, username=username)
208236
if not user:
209237
raise HTTPException(status_code=401, detail="User not found")
210-
items_count = len(user.items)
238+
239+
items_count = db.query(func.count(models.Inventory.item_id)).filter(models.Inventory.user_id == user.id).scalar()
240+
211241
if items_count >= 2:
212242
raise HTTPException(status_code=403, detail="Max. 2 Lootboxes per User!")
213243

@@ -248,9 +278,15 @@ def update_item_note(
248278
):
249279
username = get_username_from_token(token)
250280
user = crud.get_user_by_username(db, username=username)
251-
user_items = [i.id for i in user.items]
252-
if item_id not in user_items:
253-
raise HTTPException(status_code=403, detail="You are not allowed to edit the not of this Item!")
281+
282+
item_owned = db.query(models.Inventory).filter(
283+
models.Inventory.user_id == user.id,
284+
models.Inventory.item_id == item_id
285+
).first() is not None
286+
287+
if not item_owned:
288+
raise HTTPException(status_code=403, detail="You are not allowed to edit the note of this Item, or the item does not exist.")
289+
254290
item = crud.update_item_note(db, item_id, note)
255291
if item == "locked":
256292
raise HTTPException(status_code=403, detail="Item note can only be set once and cannot be changed or deleted!")
@@ -275,14 +311,26 @@ def fight(defender_id: int, token: str = Depends(oauth2_scheme), db: Session = D
275311
if not attacker or not defender:
276312
raise HTTPException(status_code=404, detail="User not found")
277313

278-
def calc_power(user):
279-
return user.power + sum(item.bonus for item in user.items)
314+
def calc_power_db(user_id: int, db_session: Session):
315+
power_data = (
316+
db_session.query(
317+
models.User.power,
318+
func.sum(models.Item.bonus).label('item_power')
319+
)
320+
.outerjoin(models.User.items)
321+
.filter(models.User.id == user_id)
322+
.group_by(models.User.id)
323+
.first()
324+
)
325+
if not power_data:
326+
return 0
327+
base_power, item_power = power_data
328+
return base_power + (item_power or 0)
280329

281-
attacker_power = calc_power(attacker)
282-
defender_power = calc_power(defender)
330+
attacker_power = calc_power_db(attacker.id, db)
331+
defender_power = calc_power_db(defender.id, db)
283332

284333
winner = attacker if attacker_power >= defender_power else defender
285-
loser = defender if winner == attacker else attacker
286334

287335
xp_win = 20
288336
xp_lose = 5
@@ -483,7 +531,13 @@ def enter_dungeon(dungeon_id: int, current_user: schemas.User = Depends(get_curr
483531
if dungeon.one_time_clear and crud.has_completed_dungeon(db, current_user.id, dungeon_id):
484532
raise HTTPException(status_code=400, detail="This dungeon can only be cleared once.")
485533

486-
user_power_with_items = current_user.power + sum(item.bonus for item in current_user.items)
534+
power_data = db.query(
535+
models.User.power,
536+
func.sum(models.Item.bonus).label('item_bonus_sum')
537+
).outerjoin(models.User.items).filter(models.User.id == current_user.id).group_by(models.User.id).first()
538+
539+
base_power, item_bonus = power_data
540+
user_power_with_items = base_power + (item_bonus or 0)
487541

488542
new_level = None
489543

@@ -515,16 +569,25 @@ def enter_dungeon(dungeon_id: int, current_user: schemas.User = Depends(get_curr
515569

516570

517571
async def cleanup_old_records():
572+
"""
573+
Periodically cleans up old records from the database and deletes associated
574+
user upload folders asynchronously.
575+
"""
518576
while True:
519-
await asyncio.sleep(60)
577+
await asyncio.sleep(120)
520578
db = SessionLocal()
579+
loop = asyncio.get_event_loop()
521580
try:
522581
ten_minutes_ago = datetime.utcnow() - timedelta(minutes=10)
523582

524-
users_to_delete = db.query(models.User).filter(models.User.created_at < ten_minutes_ago).all()
525-
deleted_usernames = [user.username for user in users_to_delete]
583+
users_to_delete_query = db.query(models.User.username).filter(
584+
models.User.created_at < ten_minutes_ago
585+
)
586+
deleted_usernames = [u[0] for u in users_to_delete_query.all()]
526587

527-
db.query(models.Fight).filter(models.Fight.created_at < ten_minutes_ago).delete(synchronize_session=False)
588+
db.query(models.Fight).filter(
589+
models.Fight.created_at < ten_minutes_ago
590+
).delete(synchronize_session=False)
528591

529592
dungeons_to_clear_subquery = db.query(models.Dungeon.id).filter(
530593
(models.Dungeon.one_time_clear == False) | (models.Dungeon.one_time_clear == None)
@@ -535,8 +598,13 @@ async def cleanup_old_records():
535598
models.CompletedDungeon.dungeon_id.in_(dungeons_to_clear_subquery)
536599
).delete(synchronize_session=False)
537600

538-
db.query(models.User).filter(models.User.created_at < ten_minutes_ago).delete(synchronize_session=False)
539-
db.query(models.Item).filter(models.Item.created_at < ten_minutes_ago).delete(synchronize_session=False)
601+
db.query(models.User).filter(
602+
models.User.created_at < ten_minutes_ago
603+
).delete(synchronize_session=False)
604+
605+
db.query(models.Item).filter(
606+
models.Item.created_at < ten_minutes_ago
607+
).delete(synchronize_session=False)
540608

541609
db.query(models.Dungeon).filter(
542610
models.Dungeon.created_at < ten_minutes_ago,
@@ -549,7 +617,7 @@ async def cleanup_old_records():
549617
user_dir = os.path.join("uploads", username)
550618
if os.path.exists(user_dir):
551619
try:
552-
shutil.rmtree(user_dir)
620+
await loop.run_in_executor(None, shutil.rmtree, user_dir)
553621
print(f"Deleted upload folder for user {username}")
554622
except Exception as e:
555623
print(f"Failed to delete upload folder for user {username}: {e}")
@@ -569,38 +637,24 @@ async def upload_dungeon_image(file: UploadFile = File(...),
569637
if not file.content_type.startswith("image/"):
570638
raise HTTPException(status_code=400, detail="Only image files are allowed.")
571639

572-
user_upload_dir = f"uploads/{current_user.username}"
573-
os.makedirs(user_upload_dir, exist_ok=True)
574-
575-
file_path = os.path.join(user_upload_dir, file.filename)
576-
577-
if ".." in file.filename or "/" in file.filename:
640+
safe_filename = os.path.basename(file.filename)
641+
if not safe_filename or ".." in safe_filename or "/" in safe_filename:
578642
raise HTTPException(status_code=400, detail="Invalid filename.")
579643

580-
if os.path.exists(file_path):
581-
raise HTTPException(status_code=400, detail="File with this name already exists for the user.")
582-
583-
with open(file_path, "wb") as buffer:
584-
shutil.copyfileobj(file.file, buffer)
585-
586-
return {"filename": file.filename, "detail": "Image uploaded successfully to user directory."}
587-
588-
589-
@app.get("/images", response_model=schemas.ImageList)
590-
def get_dungeon_images(current_user: schemas.User = Depends(get_current_active_user)):
591-
default_images = []
592-
user_images = []
644+
user_upload_dir = f"uploads/{current_user.username}"
645+
file_path = os.path.join(user_upload_dir, safe_filename)
593646

594-
default_dir = "uploads/default"
595-
user_dir = f"uploads/{current_user.username}"
647+
loop = asyncio.get_event_loop()
648+
await loop.run_in_executor(None, os.makedirs, user_upload_dir, 0o755, True)
596649

597-
if os.path.exists(default_dir):
598-
default_images = [f for f in os.listdir(default_dir) if os.path.isfile(os.path.join(default_dir, f))]
650+
if await loop.run_in_executor(None, os.path.exists, file_path):
651+
raise HTTPException(status_code=400, detail="File with this name already exists for the user.")
599652

600-
if os.path.exists(user_dir):
601-
user_images = [f for f in os.listdir(user_dir) if os.path.isfile(os.path.join(user_dir, f))]
653+
async with aiofiles.open(file_path, "wb") as buffer:
654+
content = await file.read()
655+
await buffer.write(content)
602656

603-
return {"default": default_images, "user": user_images}
657+
return {"filename": safe_filename, "detail": "Image uploaded successfully to user directory."}
604658

605659

606660
@app.get("/images/{filename:path}")
@@ -610,4 +664,4 @@ async def get_private_dungeon_image(filename: str, current_user: schemas.User =
610664
if not os.path.exists(user_image_path):
611665
raise HTTPException(status_code=404, detail="Image not found or you do not have permission to access it.")
612666

613-
return FileResponse(user_image_path)
667+
return FileResponse(user_image_path)

0 commit comments

Comments
 (0)