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:
2026-10-11 20:21:35 +03:30
parent d32d98d3f6
commit feaa5eb540
24 changed files with 585 additions and 35 deletions

View 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'))