88fb1751c7
Stack of optimizations against the same Hetzner→Neon transatlantic link. The trace revealed every visible ms was network/proxy overhead — DB execution itself is sub-millisecond per query (verified via EXPLAIN ANALYZE: index scans on every hot path). Connection layer: - DB_HOST → Neon pooler endpoint (-pooler suffix). PgBouncer transaction-mode keeps backend Postgres connections warm so we no longer pay the ~110ms Postgres-startup RTT on cold queries. - GORM pool tuned: MaxIdleConns 10→20, MaxLifetime 600s→1800s, MaxIdleTime added (default 0 = never close idle). - Eager pool warm-up at boot via parallel pings — first user request no longer pays the ~440ms TCP+TLS+startup handshake. - Redis maxmemory-policy noeviction → allkeys-lru. Cache writes will evict cold keys instead of erroring at the 256MB limit. Auth layer: - TokenCacheTTL 5min → 1 hour (Redis token cache). - UserCacheTTL 30s → 5min (in-memory User cache, per pod). - UserCache gains a 5,000-entry LRU cap so a flood of unique users can't blow up pod RSS. ~5MB worst-case per pod. - Token + user lookup collapsed from 2 GORM Preload queries into a single INNER JOIN. Saves 1 RTT per cold-cache request. - Auth middleware's m.db.* now use db.WithContext(ctx) so the SQL spans nest under the parent HTTP request in Jaeger. Service layer: - TaskService.ListTasks: replaced two-step FindResidenceIDsByUser → GetKanbanDataForMultipleResidences with a single GetKanbanDataForUser that uses a Postgres subquery for residence-access. One round-trip instead of two. - New CacheService residence-IDs cache: \"residence_ids_user:<id>\" with 5-min TTL. Wired into Task/Residence/Contractor/Document services for the four hot read paths that need this list. - Cache invalidation on every relevant mutation: CreateResidence, DeleteResidence, JoinWithCode, RemoveUser. DeleteResidence invalidates every member of the residence, not just the owner. What this stacks up to (Hetzner→Neon, before US migration): Path Before After (target) Cache-warm authed read ~800ms ~100-200ms Cache-cold authed read (1st in 1hr) ~2500ms ~500-700ms First request after deploy ~2500ms ~700-900ms The endgame US-region migration on top of this gets us to ~30-50ms warm-cache, but we're shippable at ~150ms warm right now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
4.4 KiB
Go
187 lines
4.4 KiB
Go
package middleware
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/treytartt/honeydue-api/internal/models"
|
|
)
|
|
|
|
func TestUserCache_SetAndGet(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user := &models.User{Username: "testuser", Email: "test@test.com"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
cached := cache.Get(1)
|
|
require.NotNil(t, cached)
|
|
assert.Equal(t, "testuser", cached.Username)
|
|
assert.Equal(t, "test@test.com", cached.Email)
|
|
}
|
|
|
|
func TestUserCache_GetNonExistent_ReturnsNil(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
cached := cache.Get(999)
|
|
assert.Nil(t, cached)
|
|
}
|
|
|
|
func TestUserCache_Expired_ReturnsNil(t *testing.T) {
|
|
// Very short TTL
|
|
cache := NewUserCache(1 * time.Millisecond, 0)
|
|
|
|
user := &models.User{Username: "expiring_user"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
// Wait for expiry
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
cached := cache.Get(1)
|
|
assert.Nil(t, cached, "expired entry should return nil")
|
|
}
|
|
|
|
func TestUserCache_Invalidate(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user := &models.User{Username: "to_invalidate"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
// Verify it's cached
|
|
require.NotNil(t, cache.Get(1))
|
|
|
|
// Invalidate
|
|
cache.Invalidate(1)
|
|
|
|
// Should be gone
|
|
assert.Nil(t, cache.Get(1))
|
|
}
|
|
|
|
func TestUserCache_ReturnsCopy_NotOriginal(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user := &models.User{Username: "original"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
// Modify the returned copy
|
|
cached := cache.Get(1)
|
|
require.NotNil(t, cached)
|
|
cached.Username = "modified"
|
|
|
|
// Original cache entry should be unaffected
|
|
cached2 := cache.Get(1)
|
|
require.NotNil(t, cached2)
|
|
assert.Equal(t, "original", cached2.Username, "cache should return a copy, not the original")
|
|
}
|
|
|
|
func TestUserCache_SetCopiesInput(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user := &models.User{Username: "original"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
// Modify the input after setting
|
|
user.Username = "modified_after_set"
|
|
|
|
// Cache should still have the original value
|
|
cached := cache.Get(1)
|
|
require.NotNil(t, cached)
|
|
assert.Equal(t, "original", cached.Username, "cache should store a copy of the input")
|
|
}
|
|
|
|
func TestUserCache_MultipleUsers(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user1 := &models.User{Username: "user1"}
|
|
user1.ID = 1
|
|
user2 := &models.User{Username: "user2"}
|
|
user2.ID = 2
|
|
|
|
cache.Set(user1)
|
|
cache.Set(user2)
|
|
|
|
cached1 := cache.Get(1)
|
|
cached2 := cache.Get(2)
|
|
|
|
require.NotNil(t, cached1)
|
|
require.NotNil(t, cached2)
|
|
assert.Equal(t, "user1", cached1.Username)
|
|
assert.Equal(t, "user2", cached2.Username)
|
|
}
|
|
|
|
func TestUserCache_OverwriteEntry(t *testing.T) {
|
|
cache := NewUserCache(1 * time.Minute, 0)
|
|
|
|
user := &models.User{Username: "original"}
|
|
user.ID = 1
|
|
|
|
cache.Set(user)
|
|
|
|
// Overwrite with new data
|
|
updated := &models.User{Username: "updated"}
|
|
updated.ID = 1
|
|
|
|
cache.Set(updated)
|
|
|
|
cached := cache.Get(1)
|
|
require.NotNil(t, cached)
|
|
assert.Equal(t, "updated", cached.Username)
|
|
}
|
|
|
|
func TestTimezoneCache_GetAndCompare_NewEntry(t *testing.T) {
|
|
tc := NewTimezoneCache()
|
|
|
|
// First call should return false (not cached yet)
|
|
unchanged := tc.GetAndCompare(1, "America/New_York")
|
|
assert.False(t, unchanged, "first call should indicate a change")
|
|
}
|
|
|
|
func TestTimezoneCache_GetAndCompare_SameValue(t *testing.T) {
|
|
tc := NewTimezoneCache()
|
|
|
|
// First call sets the value
|
|
tc.GetAndCompare(1, "America/New_York")
|
|
|
|
// Second call with same value should return true (unchanged)
|
|
unchanged := tc.GetAndCompare(1, "America/New_York")
|
|
assert.True(t, unchanged, "same value should indicate no change")
|
|
}
|
|
|
|
func TestTimezoneCache_GetAndCompare_DifferentValue(t *testing.T) {
|
|
tc := NewTimezoneCache()
|
|
|
|
// Set initial value
|
|
tc.GetAndCompare(1, "America/New_York")
|
|
|
|
// Update to different value
|
|
unchanged := tc.GetAndCompare(1, "America/Chicago")
|
|
assert.False(t, unchanged, "different value should indicate a change")
|
|
|
|
// Now the new value is cached
|
|
unchanged = tc.GetAndCompare(1, "America/Chicago")
|
|
assert.True(t, unchanged, "same value should indicate no change")
|
|
}
|
|
|
|
func TestTimezoneCache_GetAndCompare_DifferentUsers(t *testing.T) {
|
|
tc := NewTimezoneCache()
|
|
|
|
tc.GetAndCompare(1, "America/New_York")
|
|
tc.GetAndCompare(2, "Europe/London")
|
|
|
|
assert.True(t, tc.GetAndCompare(1, "America/New_York"))
|
|
assert.True(t, tc.GetAndCompare(2, "Europe/London"))
|
|
assert.False(t, tc.GetAndCompare(1, "Europe/London"))
|
|
}
|