3.28.2: Оптимизация загрузки деталей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s
This commit is contained in:
@@ -243,10 +243,17 @@ type Subtask struct {
|
||||
Rewards []Reward `json:"rewards"`
|
||||
}
|
||||
|
||||
type WishlistInfo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Task Task `json:"task"`
|
||||
Rewards []Reward `json:"rewards"`
|
||||
Subtasks []Subtask `json:"subtasks"`
|
||||
WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"`
|
||||
// Test-specific fields (only present if task has config_id)
|
||||
WordsCount *int `json:"words_count,omitempty"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
@@ -2889,6 +2896,18 @@ func (a *App) initAuthDB() error {
|
||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||
}
|
||||
|
||||
// Apply migration 028: Optimize task queries with composite index
|
||||
if err := a.applyMigration028(); err != nil {
|
||||
log.Printf("Warning: Failed to apply migration 028: %v", err)
|
||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||
}
|
||||
|
||||
// Apply migration 029: Add covering indexes
|
||||
if err := a.applyMigration029(); err != nil {
|
||||
log.Printf("Warning: Failed to apply migration 029: %v", err)
|
||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||
}
|
||||
|
||||
// Clean up expired refresh tokens (only those with expiration date set)
|
||||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||||
|
||||
@@ -3336,6 +3355,96 @@ func (a *App) applyMigration025() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigration028 применяет миграцию 028_optimize_task_queries.sql
|
||||
func (a *App) applyMigration028() error {
|
||||
log.Printf("Applying migration 028: Optimize task queries with composite index")
|
||||
|
||||
// Проверяем, существует ли уже индекс
|
||||
var exists bool
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname = 'idx_tasks_id_user_deleted'
|
||||
)
|
||||
`).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if index exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
log.Printf("Migration 028 already applied (index idx_tasks_id_user_deleted exists), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Читаем SQL файл миграции
|
||||
migrationPath := "/migrations/028_optimize_task_queries.sql"
|
||||
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||
// Пробуем альтернативный путь (для локальной разработки)
|
||||
migrationPath = "play-life-backend/migrations/028_optimize_task_queries.sql"
|
||||
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||
migrationPath = "migrations/028_optimize_task_queries.sql"
|
||||
}
|
||||
}
|
||||
|
||||
migrationSQL, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
|
||||
}
|
||||
|
||||
// Выполняем миграцию
|
||||
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
|
||||
return fmt.Errorf("failed to execute migration 028: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration 028 applied successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigration029 применяет миграцию 029_add_covering_indexes.sql
|
||||
func (a *App) applyMigration029() error {
|
||||
log.Printf("Applying migration 029: Add covering indexes")
|
||||
|
||||
// Проверяем, существует ли уже индекс для подзадач
|
||||
var exists bool
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname = 'idx_tasks_parent_deleted_covering'
|
||||
)
|
||||
`).Scan(&exists)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if index exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
log.Printf("Migration 029 already applied (covering indexes exist), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Читаем SQL файл миграции
|
||||
migrationPath := "/migrations/029_add_covering_indexes.sql"
|
||||
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||
// Пробуем альтернативный путь (для локальной разработки)
|
||||
migrationPath = "play-life-backend/migrations/029_add_covering_indexes.sql"
|
||||
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||
migrationPath = "migrations/029_add_covering_indexes.sql"
|
||||
}
|
||||
}
|
||||
|
||||
migrationSQL, err := os.ReadFile(migrationPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
|
||||
}
|
||||
|
||||
// Выполняем миграцию
|
||||
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
|
||||
return fmt.Errorf("failed to execute migration 029: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration 029 applied successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) initPlayLifeDB() error {
|
||||
// Создаем таблицу projects
|
||||
createProjectsTable := `
|
||||
@@ -7331,6 +7440,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Error querying subtasks: %v", err)
|
||||
} else {
|
||||
defer subtaskRows.Close()
|
||||
subtaskMap := make(map[int]*Subtask)
|
||||
subtaskIDs := make([]int, 0)
|
||||
|
||||
for subtaskRows.Next() {
|
||||
var subtaskTask Task
|
||||
var subtaskRewardMessage sql.NullString
|
||||
@@ -7356,33 +7468,56 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
|
||||
}
|
||||
|
||||
// Получаем награды подзадачи
|
||||
subtaskRewards := make([]Reward, 0)
|
||||
subtaskRewardRows, err := a.DB.Query(`
|
||||
SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
|
||||
subtaskIDs = append(subtaskIDs, subtaskTask.ID)
|
||||
subtask := Subtask{
|
||||
Task: subtaskTask,
|
||||
Rewards: make([]Reward, 0),
|
||||
}
|
||||
subtaskMap[subtaskTask.ID] = &subtask
|
||||
}
|
||||
|
||||
// Загружаем все награды всех подзадач одним запросом
|
||||
if len(subtaskIDs) > 0 {
|
||||
placeholders := make([]string, len(subtaskIDs))
|
||||
args := make([]interface{}, len(subtaskIDs))
|
||||
for i, id := range subtaskIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT rc.task_id, rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
|
||||
FROM reward_configs rc
|
||||
JOIN projects p ON rc.project_id = p.id
|
||||
WHERE rc.task_id = $1
|
||||
ORDER BY rc.position
|
||||
`, subtaskTask.ID)
|
||||
WHERE rc.task_id = ANY(ARRAY[%s])
|
||||
ORDER BY rc.task_id, rc.position
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
if err == nil {
|
||||
subtaskRewardRows, err := a.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Printf("Error querying subtask rewards: %v", err)
|
||||
} else {
|
||||
defer subtaskRewardRows.Close()
|
||||
for subtaskRewardRows.Next() {
|
||||
var taskID int
|
||||
var reward Reward
|
||||
err := subtaskRewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
|
||||
err := subtaskRewardRows.Scan(&taskID, &reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning subtask reward: %v", err)
|
||||
continue
|
||||
}
|
||||
subtaskRewards = append(subtaskRewards, reward)
|
||||
if subtask, exists := subtaskMap[taskID]; exists {
|
||||
subtask.Rewards = append(subtask.Rewards, reward)
|
||||
}
|
||||
}
|
||||
subtaskRewardRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
subtasks = append(subtasks, Subtask{
|
||||
Task: subtaskTask,
|
||||
Rewards: subtaskRewards,
|
||||
})
|
||||
// Преобразуем map в slice, сохраняя порядок по ID
|
||||
for _, id := range subtaskIDs {
|
||||
if subtask, exists := subtaskMap[id]; exists {
|
||||
subtasks = append(subtasks, *subtask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7392,6 +7527,32 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
Subtasks: subtasks,
|
||||
}
|
||||
|
||||
// Если задача связана с wishlist, загружаем базовую информацию о wishlist
|
||||
if wishlistID.Valid {
|
||||
var wishlistName string
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT name
|
||||
FROM wishlist_items
|
||||
WHERE id = $1 AND deleted = FALSE
|
||||
`, wishlistID.Int64).Scan(&wishlistName)
|
||||
|
||||
if err == nil {
|
||||
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
|
||||
if err != nil {
|
||||
log.Printf("Error checking wishlist unlock status: %v", err)
|
||||
unlocked = false
|
||||
}
|
||||
|
||||
response.WishlistInfo = &WishlistInfo{
|
||||
ID: int(wishlistID.Int64),
|
||||
Name: wishlistName,
|
||||
Unlocked: unlocked,
|
||||
}
|
||||
} else if err != sql.ErrNoRows {
|
||||
log.Printf("Error loading wishlist info for task %d: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Если задача - тест (есть config_id), загружаем данные конфигурации
|
||||
if configID.Valid {
|
||||
var wordsCount int
|
||||
|
||||
14
play-life-backend/migrations/028_optimize_task_queries.sql
Normal file
14
play-life-backend/migrations/028_optimize_task_queries.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration: Optimize task queries with composite index
|
||||
-- Date: 2026-01-24
|
||||
--
|
||||
-- This migration adds a composite index to optimize the task detail query:
|
||||
-- WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||
--
|
||||
-- The index uses a partial index with WHERE deleted = FALSE to reduce index size
|
||||
-- and improve query performance for active (non-deleted) tasks.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_id_user_deleted
|
||||
ON tasks(id, user_id, deleted)
|
||||
WHERE deleted = FALSE;
|
||||
|
||||
COMMENT ON INDEX idx_tasks_id_user_deleted IS 'Composite index for optimizing task detail queries with id, user_id, and deleted filter. Partial index for non-deleted tasks only.';
|
||||
25
play-life-backend/migrations/029_add_covering_indexes.sql
Normal file
25
play-life-backend/migrations/029_add_covering_indexes.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Migration: Add covering indexes for task detail queries
|
||||
-- Date: 2026-01-25
|
||||
--
|
||||
-- This migration adds covering indexes to optimize queries by including
|
||||
-- all needed columns in the index, avoiding table lookups.
|
||||
--
|
||||
-- Covering indexes allow PostgreSQL to perform index-only scans,
|
||||
-- getting all data directly from the index without accessing the table.
|
||||
|
||||
-- Covering index for subtasks query
|
||||
-- Includes all columns needed for subtasks selection to avoid table lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent_deleted_covering
|
||||
ON tasks(parent_task_id, deleted, id)
|
||||
INCLUDE (name, completed, last_completed_at, reward_message, progression_base)
|
||||
WHERE deleted = FALSE;
|
||||
|
||||
-- Covering index for wishlist name lookup
|
||||
-- Includes name and deleted flag for quick lookup without table access
|
||||
CREATE INDEX IF NOT EXISTS idx_wishlist_items_id_deleted_covering
|
||||
ON wishlist_items(id, deleted)
|
||||
INCLUDE (name)
|
||||
WHERE deleted = FALSE;
|
||||
|
||||
COMMENT ON INDEX idx_tasks_parent_deleted_covering IS 'Covering index for subtasks query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance.';
|
||||
COMMENT ON INDEX idx_wishlist_items_id_deleted_covering IS 'Covering index for wishlist name lookup - includes name to avoid table lookup. Enables index-only scans for better performance.';
|
||||
Reference in New Issue
Block a user