Forráskód Böngészése

perf(ui): reduce amount of sql queries to get unread entries

Every list page load now executes one fewer database round-trip, eliminating a
full 3-table JOIN (`entries` + `feeds` + `categories`) that was previously done
solely for counting. The `unread_entries.go` page haw two fewer SQL queries in
the common case.
jvoisin 3 hónapja
szülő
commit
d5c37d7d12

+ 1 - 7
internal/api/entry_handlers.go

@@ -185,13 +185,7 @@ func (h *handler) findEntries(w http.ResponseWriter, r *http.Request, feedID int
 
 	configureFilters(builder, r)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.JSONServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.JSONServerError(w, r, err)
 		return

+ 34 - 6
internal/storage/entry_query_builder.go

@@ -257,8 +257,29 @@ func (e *EntryQueryBuilder) GetEntry() (*model.Entry, error) {
 
 // GetEntries returns a list of entries that match the condition.
 func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
+	entries, _, err := e.fetchEntries(false)
+	return entries, err
+}
+
+// GetEntriesWithCount returns a list of entries and the total count of matching
+// rows (ignoring limit/offset) in a single query using a window function.
+// This avoids a separate CountEntries() round-trip.
+func (e *EntryQueryBuilder) GetEntriesWithCount() (model.Entries, int, error) {
+	return e.fetchEntries(true)
+}
+
+// fetchEntries is the shared implementation for GetEntries and GetEntriesWithCount.
+// When withCount is true, count(*) OVER() is included in the SELECT and the total
+// count of matching rows is returned; otherwise the returned count is 0.
+func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, error) {
+	countColumn := ""
+	if withCount {
+		countColumn = "count(*) OVER(),"
+	}
+
 	query := `
 		SELECT
+			` + countColumn + `
 			e.id,
 			e.user_id,
 			e.feed_id,
@@ -311,13 +332,14 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 
 	rows, err := e.store.db.Query(query, e.args...)
 	if err != nil {
-		return nil, fmt.Errorf("store: unable to get entries: %v", err)
+		return nil, 0, fmt.Errorf("store: unable to get entries: %v", err)
 	}
 	defer rows.Close()
 
 	entries := make(model.Entries, 0)
 	entryMap := make(map[int64]*model.Entry)
 	var entryIDs []int64
+	var totalCount int
 
 	for rows.Next() {
 		var iconID sql.NullInt64
@@ -326,7 +348,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 
 		entry := model.NewEntry()
 
-		err := rows.Scan(
+		dest := []any{
 			&entry.ID,
 			&entry.UserID,
 			&entry.FeedID,
@@ -363,10 +385,16 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 			&iconID,
 			&externalIconID,
 			&tz,
-		)
+		}
+
+		if withCount {
+			dest = append([]any{&totalCount}, dest...)
+		}
+
+		err := rows.Scan(dest...)
 
 		if err != nil {
-			return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
+			return nil, 0, fmt.Errorf("store: unable to fetch entry row: %v", err)
 		}
 
 		if iconID.Valid && externalIconID.Valid && externalIconID.String != "" {
@@ -396,7 +424,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 	if e.fetchEnclosures && len(entryIDs) > 0 {
 		enclosures, err := e.store.GetEnclosuresForEntries(entryIDs)
 		if err != nil {
-			return nil, fmt.Errorf("store: unable to fetch enclosures: %w", err)
+			return nil, 0, fmt.Errorf("store: unable to fetch enclosures: %w", err)
 		}
 
 		for entryID, entryEnclosures := range enclosures {
@@ -406,7 +434,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 		}
 	}
 
-	return entries, nil
+	return entries, totalCount, nil
 }
 
 // GetEntryIDs returns a list of entry IDs that match the condition.

+ 1 - 7
internal/ui/category_entries.go

@@ -41,13 +41,7 @@ func (h *handler) showCategoryEntriesPage(w http.ResponseWriter, r *http.Request
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/category_entries_all.go

@@ -41,13 +41,7 @@ func (h *handler) showCategoryEntriesAllPage(w http.ResponseWriter, r *http.Requ
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/category_entries_starred.go

@@ -42,13 +42,7 @@ func (h *handler) showCategoryEntriesStarredPage(w http.ResponseWriter, r *http.
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/feed_entries.go

@@ -41,13 +41,7 @@ func (h *handler) showFeedEntriesPage(w http.ResponseWriter, r *http.Request) {
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/feed_entries_all.go

@@ -41,13 +41,7 @@ func (h *handler) showFeedEntriesAllPage(w http.ResponseWriter, r *http.Request)
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/history_entries.go

@@ -28,13 +28,7 @@ func (h *handler) showHistoryPage(w http.ResponseWriter, r *http.Request) {
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/search.go

@@ -37,13 +37,7 @@ func (h *handler) showSearchPage(w http.ResponseWriter, r *http.Request) {
 		builder.WithOffset(offset)
 		builder.WithLimit(user.EntriesPerPage)
 
-		entries, err = builder.GetEntries()
-		if err != nil {
-			response.HTMLServerError(w, r, err)
-			return
-		}
-
-		entriesCount, err = builder.CountEntries()
+		entries, entriesCount, err = builder.GetEntriesWithCount()
 		if err != nil {
 			response.HTMLServerError(w, r, err)
 			return

+ 1 - 7
internal/ui/shared_entries.go

@@ -27,13 +27,7 @@ func (h *handler) sharedEntries(w http.ResponseWriter, r *http.Request) {
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/starred_entries.go

@@ -29,13 +29,7 @@ func (h *handler) showStarredPage(w http.ResponseWriter, r *http.Request) {
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 1 - 7
internal/ui/tag_entries_all.go

@@ -37,13 +37,7 @@ func (h *handler) showTagEntriesAllPage(w http.ResponseWriter, r *http.Request)
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 
-	entries, err := builder.GetEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	count, err := builder.CountEntries()
+	entries, count, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return

+ 18 - 14
internal/ui/unread_entries.go

@@ -23,30 +23,34 @@ func (h *handler) showUnreadPage(w http.ResponseWriter, r *http.Request) {
 	offset := request.QueryIntParam(r, "offset", 0)
 	builder := h.store.NewEntryQueryBuilder(user.ID)
 	builder.WithStatus(model.EntryStatusUnread)
-	builder.WithGloballyVisible()
-	countUnread, err := builder.CountEntries()
-	if err != nil {
-		response.HTMLServerError(w, r, err)
-		return
-	}
-
-	if offset >= countUnread {
-		offset = 0
-	}
-
-	builder = h.store.NewEntryQueryBuilder(user.ID)
-	builder.WithStatus(model.EntryStatusUnread)
 	builder.WithSorting(user.EntryOrder, user.EntryDirection)
 	builder.WithSorting("id", user.EntryDirection)
 	builder.WithOffset(offset)
 	builder.WithLimit(user.EntriesPerPage)
 	builder.WithGloballyVisible()
-	entries, err := builder.GetEntries()
+
+	entries, countUnread, err := builder.GetEntriesWithCount()
 	if err != nil {
 		response.HTMLServerError(w, r, err)
 		return
 	}
 
+	if offset >= countUnread && countUnread > 0 {
+		offset = 0
+		builder = h.store.NewEntryQueryBuilder(user.ID)
+		builder.WithStatus(model.EntryStatusUnread)
+		builder.WithSorting(user.EntryOrder, user.EntryDirection)
+		builder.WithSorting("id", user.EntryDirection)
+		builder.WithLimit(user.EntriesPerPage)
+		builder.WithGloballyVisible()
+
+		entries, countUnread, err = builder.GetEntriesWithCount()
+		if err != nil {
+			response.HTMLServerError(w, r, err)
+			return
+		}
+	}
+
 	sess := session.New(h.store, request.SessionID(r))
 	view := view.New(h.tpl, r, sess)
 	view.Set("entries", entries)