Cut /api/tasks/ p99 from ~2500ms toward ~150-300ms
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>
This commit is contained in:
@@ -640,6 +640,54 @@ func (r *TaskRepository) GetKanbanDataForMultipleResidences(residenceIDs []uint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetKanbanDataForUser fetches every task across every residence the user has
|
||||
// access to, in a single round-trip. Replaces the two-step
|
||||
// FindResidenceIDsByUser → GetKanbanDataForMultipleResidences pattern, saving
|
||||
// one transatlantic RTT per ListTasks call (~110ms on Hetzner→Neon).
|
||||
//
|
||||
// The residence-access check runs as a subquery on Postgres rather than a
|
||||
// separate Go-side round-trip; Postgres's planner already turns it into a
|
||||
// hash semi-join, so there's no perf cost vs the explicit IN(...) approach.
|
||||
func (r *TaskRepository) GetKanbanDataForUser(userID uint, daysThreshold int, now time.Time) (*models.KanbanBoard, error) {
|
||||
residenceSubquery := r.db.Table("residence_residence").
|
||||
Select("id").
|
||||
Where("is_active = ?", true).
|
||||
Where("owner_id = ? OR id IN (?)",
|
||||
userID,
|
||||
r.db.Table("residence_residence_users").Select("residence_id").Where("user_id = ?", userID),
|
||||
)
|
||||
|
||||
var allTasks []models.Task
|
||||
query := r.db.Model(&models.Task{}).
|
||||
Where("task_task.residence_id IN (?)", residenceSubquery).
|
||||
Preload("CreatedBy").
|
||||
Preload("AssignedTo").
|
||||
Preload("Residence").
|
||||
Preload("Completions", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id", "task_id", "completed_at")
|
||||
}).
|
||||
Scopes(task.ScopeKanbanOrder)
|
||||
|
||||
if err := query.Find(&allTasks).Error; err != nil {
|
||||
return nil, fmt.Errorf("get tasks for kanban: %w", err)
|
||||
}
|
||||
|
||||
columnMap := categorization.CategorizeTasksIntoColumnsWithTime(allTasks, daysThreshold, now)
|
||||
columns := buildKanbanColumns(
|
||||
columnMap[categorization.ColumnOverdue],
|
||||
columnMap[categorization.ColumnInProgress],
|
||||
columnMap[categorization.ColumnDueSoon],
|
||||
columnMap[categorization.ColumnUpcoming],
|
||||
columnMap[categorization.ColumnCompleted],
|
||||
)
|
||||
|
||||
return &models.KanbanBoard{
|
||||
Columns: columns,
|
||||
DaysThreshold: daysThreshold,
|
||||
ResidenceID: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// === Lookup Operations ===
|
||||
|
||||
// GetAllCategories returns all task categories
|
||||
|
||||
Reference in New Issue
Block a user