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:
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'))
|
||||
Reference in New Issue
Block a user