Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -47,3 +48,35 @@ def compute_scores(period, days=7, limit=50, include_zero=False):
|
||||
scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0]
|
||||
scores.sort(key=lambda p: (-p[1], p[0]))
|
||||
return scores[:limit]
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
# نسخهی کهنه فقط برای لحظهای است که کسی دیگر در حالِ ساختنِ نسخهی تازه است.
|
||||
_STALE_TTL = 24 * 3600
|
||||
_LOCK_TTL = 30
|
||||
|
||||
|
||||
def cached_scores(period):
|
||||
"""
|
||||
compute_scores با کش، بی «ازدحامِ لحظهی انقضا»: با `get_or_set` ساده، وقتی کش منقضی میشد هر
|
||||
درخواستی که در همان لحظه میرسید خودش کلِ جدول را از نو حساب میکرد. حالا فقط درخواستی که قفل را
|
||||
بگیرد حساب میکند و بقیه آخرین نسخه را (حداکثر چند ثانیه کهنهتر) میگیرند. اگر هیچ نسخهای نیست
|
||||
(اولین بار یا کشِ خالیشده) خودشان حساب میکنند، چون چیزِ دیگری برای نشان دادن نیست.
|
||||
کش Redisِ مشترکِ workerهاست؛ با قطعیِ Redis (config/cache.py) هر درخواست مستقیم حساب میکند.
|
||||
"""
|
||||
fresh_key, stale_key, lock_key = (f'leaderboard:{period}', f'leaderboard:{period}:stale',
|
||||
f'leaderboard:{period}:lock')
|
||||
scores = cache.get(fresh_key)
|
||||
if scores is not None:
|
||||
return scores
|
||||
if cache.add(lock_key, 1, _LOCK_TTL):
|
||||
try:
|
||||
scores = compute_scores(period)
|
||||
cache.set(fresh_key, scores, LEADERBOARD_CACHE_TTL)
|
||||
cache.set(stale_key, scores, _STALE_TTL)
|
||||
finally:
|
||||
cache.delete(lock_key)
|
||||
return scores
|
||||
stale = cache.get(stale_key)
|
||||
return stale if stale is not None else compute_scores(period)
|
||||
|
||||
43
progress/tests_leaderboard_cache.py
Normal file
43
progress/tests_leaderboard_cache.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
کشِ لیدربورد بی «ازدحامِ لحظهی انقضا» (progress/leaderboard.py، cached_scores): وقتی کش منقضی میشود
|
||||
فقط یک درخواست جدول را از نو حساب میکند و بقیه آخرین نسخه را میگیرند. همزمانیِ واقعی بینِ workerها
|
||||
آزموده نشده؛ این تستها قفل را با گذاشتنِ دستیِ کلیدش شبیهسازی میکنند.
|
||||
"""
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from progress import leaderboard
|
||||
|
||||
|
||||
class CachedScoresTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_first_call_computes_and_fills_both_copies(self):
|
||||
with mock.patch.object(leaderboard, 'compute_scores', return_value=[(1, 50)]) as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(1, 50)])
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(1, 50)])
|
||||
compute.assert_called_once_with('weekly')
|
||||
self.assertEqual(cache.get('leaderboard:weekly:stale'), [(1, 50)])
|
||||
self.assertIsNone(cache.get('leaderboard:weekly:lock'))
|
||||
|
||||
def test_while_someone_else_recomputes_the_stale_copy_is_served(self):
|
||||
cache.set('leaderboard:weekly:stale', [(2, 30)], 600)
|
||||
cache.set('leaderboard:weekly:lock', 1, 30)
|
||||
with mock.patch.object(leaderboard, 'compute_scores') as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(2, 30)])
|
||||
compute.assert_not_called()
|
||||
|
||||
def test_without_any_copy_it_computes_even_if_locked(self):
|
||||
cache.set('leaderboard:weekly:lock', 1, 30)
|
||||
with mock.patch.object(leaderboard, 'compute_scores', return_value=[(3, 10)]) as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(3, 10)])
|
||||
compute.assert_called_once()
|
||||
|
||||
def test_a_failed_recompute_releases_the_lock(self):
|
||||
with mock.patch.object(leaderboard, 'compute_scores', side_effect=RuntimeError('db')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
leaderboard.cached_scores('weekly')
|
||||
self.assertIsNone(cache.get('leaderboard:weekly:lock'))
|
||||
@@ -109,11 +109,6 @@ class WeeklyReportView(APIView):
|
||||
return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data)
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را
|
||||
# از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
|
||||
|
||||
class LeaderboardView(APIView):
|
||||
"""
|
||||
GET /api/leaderboard?period= — رتبهبندی هفتگی/ماهانه
|
||||
@@ -142,12 +137,8 @@ class LeaderboardView(APIView):
|
||||
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
|
||||
# جدولِ LeaderboardEntry خوانده میشد که فقط با اجرای دستیِ
|
||||
# rebuild_leaderboard پر میشد، پس XPی کاربرِ تازه هیچوقت دیده نمیشد.
|
||||
from django.core.cache import cache
|
||||
|
||||
from .leaderboard import compute_scores
|
||||
scores = cache.get_or_set(
|
||||
f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL,
|
||||
)
|
||||
from .leaderboard import cached_scores
|
||||
scores = cached_scores(period)
|
||||
# نام کاربر از accounts.User خونده میشه (فقط برای نمایش، بدون FK واقعی).
|
||||
from accounts.models import User as AccountUser
|
||||
names = dict(
|
||||
|
||||
Reference in New Issue
Block a user