perf(ui): push task-list filtering into SQL

LoadForListAsync loaded the entire tasks table (all lists, two Include
joins) then filtered to the selected list in C#, never using
idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes
MatchExpression, an Expression<Func<TaskEntity,bool>> that EF Core can
translate to SQL; a new TaskListFilterBase compiles it on demand for the
existing in-memory Matches(). The primary match query now runs as
db.Tasks.Where(filter.MatchExpression), with two small follow-up queries
(scoped by parent-id IN-lists) for contextual planning-parent rows and
pulled-in children, followed by an explicit re-sort to restore the global
SortOrder/CreatedAt ordering Regroup() depends on.
This commit is contained in:
mika kuns
2026-08-10 13:49:09 +02:00
parent 6a2a19cc9e
commit 7f10d45898
8 changed files with 152 additions and 35 deletions
@@ -336,26 +336,53 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
var filteredList = filter is null
? new List<TaskEntity>()
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
ct.ThrowIfCancellationRequested();
var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
? new List<TaskEntity>()
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
if (filter is not null)
{
if (existingIds.Add(c.Id))
filteredList.Add(c);
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
// child): only fetch candidates that could plausibly qualify — parents of tasks we already
// matched — instead of scanning the whole table.
var candidateParentIds = filteredList
.Where(t => t.ParentTaskId != null)
.Select(t => t.ParentTaskId!)
.Distinct()
.ToList();
if (candidateParentIds.Count > 0)
{
var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
var parentCandidates = await baseQuery
.Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
.ToListAsync(ct);
foreach (var p in parentCandidates)
if (filter.MatchesAsContext(p, filteredList))
filteredList.Add(p);
}
}
ct.ThrowIfCancellationRequested();
// Pull in every child of an already-matched top-level row, regardless of whether the child
// itself matches the filter, so subtasks render nested under their parent.
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
if (topIds.Count > 0)
{
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
var extraChildren = await baseQuery
.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
.ToListAsync(ct);
filteredList.AddRange(extraChildren);
}
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
{