From 646dbbb8f691fe859d432b5f6c98000b16acdaca Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:27:30 +0100 Subject: [PATCH] perf(repository): bound filtered item-list totals (WI-1910) The filtered, collection, QL and search list paths ran COUNT(*) over the full filter join set with no LIMIT, so the aggregate scanned every matching row on each request while the page query beside it stayed index-bounded. Wrap the filter scan in a LIMIT of 10000. Results below the cap stay exact; results at the cap report the cap. The UI already refuses to paginate past 10,000 items (Pagination.svelte maxItems), so the bound matches the existing contract. The unfiltered single-workspace count stays exact and cached. On SQLite with 200k matching rows the count drops from 87.9ms to 2.6ms. --- internal/repository/item_list.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/repository/item_list.go b/internal/repository/item_list.go index a520d4e6b..f7d70bfe7 100644 --- a/internal/repository/item_list.go +++ b/internal/repository/item_list.go @@ -555,13 +555,32 @@ func itemListCursorWhere(cursor itemListCursor) (where string, args []any, err e return " AND (i.frac_index > ? OR (i.frac_index = ? AND i.id > ?))", []any{cursor.Rank, cursor.Rank, cursor.ID}, nil } +// boundedItemListCountLimit caps the filtered, collection, QL and search list +// count. Those paths run the count over the full filter join set with no bound, +// so the cost grows with the workspace item count even though the page query +// beside it is index-bounded. Capping keeps the worst case proportional to the +// cap instead. The UI already refuses to paginate past 10,000 items and labels +// the total "(limited to 10,000)" (Pagination.svelte maxItems), so the bound +// matches the contract the client already assumes. +const boundedItemListCountLimit = 10000 + +// boundedItemListCountQuery wraps the filter scan in a LIMIT so the engine can +// stop after the cap instead of aggregating every matching row. Results below +// the cap stay exact; results at the cap report the cap. +func boundedItemListCountQuery(countFromClause, whereClause string) string { + return fmt.Sprintf( + "SELECT COUNT(*) FROM (SELECT 1 %s%s LIMIT %d) AS bounded_item_count", + countFromClause, whereClause, boundedItemListCountLimit, + ) +} + func (r *ItemRepository) buildItemListPagePlan( params ItemListParams, countFromClause, whereClause string, args []any, ) itemListPagePlan { plan := itemListPagePlan{ - countQuery: "SELECT COUNT(*) " + countFromClause + whereClause, + countQuery: boundedItemListCountQuery(countFromClause, whereClause), countArgs: args, pageFromClause: countFromClause, pageWhereClause: whereClause,