Add production hardening: monitoring, backups, caching, and high-traffic resilience

- Recovery middleware (no more crash-on-panic), request metrics/monitoring
  store + admin API, in-memory TTL caching for read-heavy option/question/
  category lookups, gzip + rate-limiting middleware, sanitized chat message
  encoding, and pg_dump-based database backups with an admin API.
- Fix N+1 queries in the discover feed, candidate detail, and match list
  endpoints by batching selection/photo/user/last-message lookups into
  single queries instead of one-per-item loops.
- Tune DB connection pooling and add HTTP server timeouts + graceful
  shutdown so the API degrades gracefully instead of falling over under load.
- Fix a null (vs empty array) JSON response for candidates/matches without
  photos or interests, which crashed the web app's swipe card.
This commit is contained in:
2026-09-19 22:17:12 +03:30
parent 15583009e0
commit 769e76313f
29 changed files with 1377 additions and 72 deletions
+134
View File
@@ -0,0 +1,134 @@
// Package monitoring یک ذخیره‌ساز سبک و درون‌حافظه‌ای برای دیدبانی درخواست‌هاست:
// آخرین درخواست‌ها (برای دیباگ لحظه‌ای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا).
// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقه‌ی
// درون‌حافظه‌ای thread-safe کافیه؛ اگه بعداً مقیاس بزرگ‌تر شد، همین Store رو می‌شه
// پشت یک exporter واقعی گذاشت.
package monitoring
import (
"sort"
"sync"
"time"
)
const bufferSize = 1000
type RequestRecord struct {
Time time.Time `json:"time"`
Method string `json:"method"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
DurationMs float64 `json:"duration_ms"`
ClientIP string `json:"client_ip"`
UserID *uint `json:"user_id,omitempty"`
}
type Summary struct {
TotalRequests int64 `json:"total_requests"`
TotalErrors int64 `json:"total_errors"`
ErrorRatePct float64 `json:"error_rate_pct"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
P95LatencyMs float64 `json:"p95_latency_ms"`
RequestsLastMin int `json:"requests_last_min"`
StartedAt time.Time `json:"started_at"`
UptimeSeconds float64 `json:"uptime_seconds"`
}
// Store یک حلقه‌ی ثابت‌اندازه (ring buffer) از آخرین درخواست‌ها به‌همراه
// شمارنده‌های تجمیعیه. نوشتن/خواندن با RWMutex محافظت می‌شه؛ هزینه‌ی هر
// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ).
type Store struct {
mu sync.RWMutex
buf []RequestRecord
next int
count int
total int64
totalErr int64
sumLatency float64
startedAt time.Time
}
func NewStore() *Store {
return &Store{
buf: make([]RequestRecord, bufferSize),
startedAt: time.Now(),
}
}
func (s *Store) Record(rec RequestRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf[s.next] = rec
s.next = (s.next + 1) % bufferSize
if s.count < bufferSize {
s.count++
}
s.total++
s.sumLatency += rec.DurationMs
if rec.StatusCode >= 400 {
s.totalErr++
}
}
// Recent آخرین n درخواست رو برمی‌گردونه (جدیدترین اول).
func (s *Store) Recent(n int) []RequestRecord {
s.mu.RLock()
defer s.mu.RUnlock()
if n > s.count {
n = s.count
}
out := make([]RequestRecord, 0, n)
idx := s.next - 1
for i := 0; i < n; i++ {
if idx < 0 {
idx = bufferSize - 1
}
out = append(out, s.buf[idx])
idx--
}
return out
}
func (s *Store) Summary() Summary {
s.mu.RLock()
defer s.mu.RUnlock()
var errRate, avgLatency float64
if s.total > 0 {
errRate = float64(s.totalErr) / float64(s.total) * 100
avgLatency = s.sumLatency / float64(s.total)
}
cutoff := time.Now().Add(-1 * time.Minute)
lastMin := 0
latencies := make([]float64, 0, s.count)
for i := 0; i < s.count; i++ {
r := s.buf[i]
if r.Time.After(cutoff) {
lastMin++
}
latencies = append(latencies, r.DurationMs)
}
sort.Float64s(latencies)
p95 := 0.0
if len(latencies) > 0 {
idx := int(float64(len(latencies)) * 0.95)
if idx >= len(latencies) {
idx = len(latencies) - 1
}
p95 = latencies[idx]
}
return Summary{
TotalRequests: s.total,
TotalErrors: s.totalErr,
ErrorRatePct: errRate,
AvgLatencyMs: avgLatency,
P95LatencyMs: p95,
RequestsLastMin: lastMin,
StartedAt: s.startedAt,
UptimeSeconds: time.Since(s.startedAt).Seconds(),
}
}
+74
View File
@@ -0,0 +1,74 @@
package monitoring
import (
"testing"
"time"
)
func TestStore_RecordAndSummary(t *testing.T) {
s := NewStore()
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10})
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20})
sum := s.Summary()
if sum.TotalRequests != 2 {
t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests)
}
if sum.TotalErrors != 1 {
t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors)
}
if sum.ErrorRatePct != 50 {
t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct)
}
if sum.AvgLatencyMs != 15 {
t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs)
}
}
func TestStore_RecentOrder(t *testing.T) {
s := NewStore()
s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200})
s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200})
s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200})
recent := s.Recent(10)
if len(recent) != 3 {
t.Fatalf("expected 3 records, got %d", len(recent))
}
// جدیدترین باید اول باشه
if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" {
t.Fatalf("expected newest-first order, got %+v", recent)
}
}
func TestStore_RecentLimit(t *testing.T) {
s := NewStore()
for i := 0; i < 5; i++ {
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200})
}
if got := len(s.Recent(2)); got != 2 {
t.Fatalf("expected limit of 2, got %d", got)
}
if got := len(s.Recent(100)); got != 5 {
t.Fatalf("expected all 5 when limit exceeds count, got %d", got)
}
}
func TestStore_RingBufferWraparound(t *testing.T) {
s := NewStore()
// بیشتر از ظرفیت بافر ثبت می‌کنیم تا رفتار wraparound رو تست کنیم
for i := 0; i < bufferSize+50; i++ {
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1})
}
sum := s.Summary()
if sum.TotalRequests != int64(bufferSize+50) {
t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests)
}
recent := s.Recent(bufferSize + 100)
if len(recent) != bufferSize {
t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent))
}
}